From 7baf1d75561c986138bf807077d6e3822911796e Mon Sep 17 00:00:00 2001 From: Risto Lahtela <24460436+Rsl1122@users.noreply.github.com> Date: Mon, 18 Jan 2021 10:50:52 +0200 Subject: [PATCH] Made it possible to share query results via url --- .../plan/delivery/rendering/html/Html.java | 12 ++ .../resolver/json/QueryJSONResolver.java | 28 +++- .../modules/SystemObjectProvidingModule.java | 8 ++ .../plan/storage/file/PlanFiles.java | 4 + .../plan/storage/json/JSONFileStorage.java | 135 ++++++++++++++++++ .../plan/storage/json/JSONStorage.java | 61 ++++++++ .../resources/assets/plan/web/js/query.js | 63 +++++--- .../main/resources/assets/plan/web/query.html | 46 ++++-- .../database/DatabaseTestComponent.java | 2 + .../utilities/dagger/PlanPluginModule.java | 5 + 10 files changed, 332 insertions(+), 32 deletions(-) create mode 100644 Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONFileStorage.java create mode 100644 Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONStorage.java diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Html.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Html.java index 83e23dc20..00d791d36 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Html.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/html/Html.java @@ -22,6 +22,7 @@ import org.apache.commons.text.TextStringBuilder; import java.io.Serializable; import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.net.URLEncoder; import java.util.HashMap; import java.util.Map; @@ -144,6 +145,17 @@ public enum Html { return builder.toString(); } + public static String decodeFromURL(String string) { + try { + return StringUtils.replace( + URLDecoder.decode(string, "UTF-8"), + " ", "+" // Decoding replaces + with spaces + ); + } catch (UnsupportedEncodingException e) { + return string; + } + } + /** * @return The HTML String */ diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/QueryJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/QueryJSONResolver.java index 6df426699..8426a2ef3 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/QueryJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/QueryJSONResolver.java @@ -34,6 +34,7 @@ import com.djrapitops.plan.storage.database.queries.filter.Filter; import com.djrapitops.plan.storage.database.queries.filter.FilterQuery; import com.djrapitops.plan.storage.database.queries.filter.QueryFilters; import com.djrapitops.plan.storage.database.queries.objects.playertable.QueryTablePlayersQuery; +import com.djrapitops.plan.storage.json.JSONStorage; import com.djrapitops.plan.utilities.java.Maps; import com.google.gson.Gson; @@ -52,6 +53,7 @@ public class QueryJSONResolver implements Resolver { private final PlanConfig config; private final DBSystem dbSystem; + private final JSONStorage jsonStorage; private final Locale locale; private final Formatters formatters; @@ -60,12 +62,14 @@ public class QueryJSONResolver implements Resolver { QueryFilters filters, PlanConfig config, DBSystem dbSystem, + JSONStorage jsonStorage, Locale locale, Formatters formatters ) { this.filters = filters; this.config = config; this.dbSystem = dbSystem; + this.jsonStorage = jsonStorage; this.locale = locale; this.formatters = formatters; } @@ -82,9 +86,25 @@ public class QueryJSONResolver implements Resolver { } private Response getResponse(Request request) { + // Attempt to find previously created result + try { + Optional previousResults = request.getQuery().get("timestamp") + .flatMap(queryTimestamp -> jsonStorage.fetchExactJson("query", Long.parseLong(queryTimestamp))); + if (previousResults.isPresent()) { + return Response.builder() + .setMimeType(MimeType.JSON) + .setJSONContent(previousResults.get().json) + .build(); + } + } catch (NumberFormatException e) { + throw new BadRequestException("Could not parse 'timestamp' into a number. Remove parameter or fix it."); + } + String q = request.getQuery().get("q").orElseThrow(() -> new BadRequestException("'q' parameter not set (expecting json array)")); String view = request.getQuery().get("view").orElseThrow(() -> new BadRequestException("'view' parameter not set (expecting json object {afterDate, afterTime, beforeDate, beforeTime})")); + long timestamp = System.currentTimeMillis(); + try { q = URLDecoder.decode(q, "UTF-8"); List queries = FilterQuery.parse(q); @@ -92,15 +112,19 @@ public class QueryJSONResolver implements Resolver { Map json = Maps.builder(String.class, Object.class) .put("path", result.getResultPath()) + .put("view", new Gson().fromJson(view, FiltersJSONResolver.ViewJSON.class)) + .put("timestamp", timestamp) .build(); if (!result.isEmpty()) { json.put("data", getDataFor(result.getResultUUIDs(), view)); } + + JSONStorage.StoredJSON stored = jsonStorage.storeJson("query", json, timestamp); + return Response.builder() .setMimeType(MimeType.JSON) - .setJSONContent(json) + .setJSONContent(stored.json) .build(); - } catch (ParseException e) { throw new BadRequestException("'view' date format was incorrect (expecting afterDate dd/mm/yyyy, afterTime hh:mm, beforeDate dd/mm/yyyy, beforeTime hh:mm}): " + e.getMessage()); } catch (IOException e) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/modules/SystemObjectProvidingModule.java b/Plan/common/src/main/java/com/djrapitops/plan/modules/SystemObjectProvidingModule.java index 2bc9faef4..f193781fe 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/modules/SystemObjectProvidingModule.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/modules/SystemObjectProvidingModule.java @@ -25,6 +25,8 @@ import com.djrapitops.plan.settings.config.PlanConfig; import com.djrapitops.plan.settings.locale.Locale; import com.djrapitops.plan.settings.locale.LocaleSystem; import com.djrapitops.plan.storage.file.JarResource; +import com.djrapitops.plan.storage.json.JSONFileStorage; +import com.djrapitops.plan.storage.json.JSONStorage; import com.djrapitops.plan.utilities.logging.ErrorLogger; import com.djrapitops.plan.utilities.logging.PluginErrorLogger; import dagger.Module; @@ -96,4 +98,10 @@ public class SystemObjectProvidingModule { return dataService; } + @Provides + @Singleton + JSONStorage provideJSONStorage(JSONFileStorage jsonFileStorage) { + return jsonFileStorage; + } + } \ No newline at end of file diff --git a/Plan/common/src/main/java/com/djrapitops/plan/storage/file/PlanFiles.java b/Plan/common/src/main/java/com/djrapitops/plan/storage/file/PlanFiles.java index 4412955e7..e3f1ddcdb 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/storage/file/PlanFiles.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/storage/file/PlanFiles.java @@ -149,4 +149,8 @@ public class PlanFiles implements SubSystem { } return Optional.empty(); } + + public Path getJSONStorageDirectory() { + return getDataDirectory().resolve("cached_json"); + } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONFileStorage.java b/Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONFileStorage.java new file mode 100644 index 000000000..c25ce8ccb --- /dev/null +++ b/Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONFileStorage.java @@ -0,0 +1,135 @@ +/* + * This file is part of Player Analytics (Plan). + * + * Plan is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License v3 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Plan is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Plan. If not, see . + */ +package com.djrapitops.plan.storage.json; + +import com.djrapitops.plan.storage.file.PlanFiles; +import com.djrapitops.plugin.logging.console.PluginLogger; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Optional; +import java.util.function.BiPredicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * In charge of storing json files on disk for later retrieval. + * + * @author Rsl1122 + */ +@Singleton +public class JSONFileStorage implements JSONStorage { + + private final PluginLogger logger; + + private final Path jsonDirectory; + private final Pattern timestampRegex = Pattern.compile(".*-([0-9]*).json"); + + @Inject + public JSONFileStorage(PlanFiles files, PluginLogger logger) { + this.logger = logger; + + jsonDirectory = files.getJSONStorageDirectory(); + } + + @Override + public StoredJSON storeJson(String identifier, String json, long timestamp) { + Path writingTo = jsonDirectory.resolve(identifier + '-' + timestamp + ".json"); + try { + Files.createDirectories(jsonDirectory); + Files.write(writingTo, json.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } catch (IOException e) { + logger.warn("Could not write a file to " + writingTo.toFile().getAbsolutePath() + ": " + e.getMessage()); + } + return new StoredJSON(json, timestamp); + } + + @Override + public Optional fetchJSON(String identifier) { + File[] stored = jsonDirectory.toFile().listFiles(); + if (stored == null) return Optional.empty(); + for (File file : stored) { + String fileName = file.getName(); + if (fileName.endsWith(".json") && fileName.startsWith(identifier)) { + return Optional.ofNullable(readStoredJSON(file)); + } + } + return Optional.empty(); + } + + private StoredJSON readStoredJSON(File from) { + Matcher timestampMatch = timestampRegex.matcher(from.getName()); + if (timestampMatch.find()) { + try (Stream lines = Files.lines(from.toPath())) { + long timestamp = Long.parseLong(timestampMatch.group(1)); + StringBuilder json = new StringBuilder(); + lines.forEach(json::append); + return new StoredJSON(json.toString(), timestamp); + } catch (IOException e) { + logger.warn(jsonDirectory.toFile().getAbsolutePath() + " file '" + from.getName() + "' could not be read: " + e.getMessage()); + } catch (NumberFormatException e) { + logger.warn(jsonDirectory.toFile().getAbsolutePath() + " contained a file '" + from.getName() + "' with improperly formatted -timestamp (could not parse number). This file was not placed there by Plan!"); + } + } else { + logger.warn(jsonDirectory.toFile().getAbsolutePath() + " contained a file '" + from.getName() + "' that has no -timestamp. This file was not placed there by Plan!"); + } + return null; + } + + @Override + public Optional fetchExactJson(String identifier, long timestamp) { + File found = jsonDirectory.resolve(identifier + "-" + timestamp + ".json").toFile(); + if (!found.exists()) return Optional.empty(); + return Optional.ofNullable(readStoredJSON(found)); + } + + @Override + public Optional fetchJsonMadeBefore(String identifier, long timestamp) { + return fetchJSONWithTimestamp(identifier, timestamp, (timestampMatch, time) -> Long.parseLong(timestampMatch.group(1)) < time); + } + + @Override + public Optional fetchJsonMadeAfter(String identifier, long timestamp) { + return fetchJSONWithTimestamp(identifier, timestamp, (timestampMatch, time) -> Long.parseLong(timestampMatch.group(1)) > time); + } + + private Optional fetchJSONWithTimestamp(String identifier, long timestamp, BiPredicate timestampComparator) { + File[] stored = jsonDirectory.toFile().listFiles(); + if (stored == null) return Optional.empty(); + for (File file : stored) { + try { + String fileName = file.getName(); + if (fileName.endsWith(".json") && fileName.startsWith(identifier)) { + Matcher timestampMatch = timestampRegex.matcher(fileName); + if (timestampMatch.find() && timestampComparator.test(timestampMatch, timestamp)) { + return Optional.ofNullable(readStoredJSON(file)); + } + } + } catch (NumberFormatException e) { + // Ignore this file, malformed timestamp + } + } + return Optional.empty(); + } +} diff --git a/Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONStorage.java b/Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONStorage.java new file mode 100644 index 000000000..a8d91ac7d --- /dev/null +++ b/Plan/common/src/main/java/com/djrapitops/plan/storage/json/JSONStorage.java @@ -0,0 +1,61 @@ +/* + * This file is part of Player Analytics (Plan). + * + * Plan is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License v3 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Plan is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Plan. If not, see . + */ +package com.djrapitops.plan.storage.json; + +import com.google.gson.Gson; + +import java.util.Optional; + +/** + * In charge of storing json somewhere for later retrieval. + * + * @author Rsl1122 + */ +public interface JSONStorage { + + default StoredJSON storeJson(String identifier, String json) { + return storeJson(identifier, json, System.currentTimeMillis()); + } + + default StoredJSON storeJson(String identifier, Object json) { + return storeJson(identifier, new Gson().toJson(json)); + } + + StoredJSON storeJson(String identifier, String json, long timestamp); + + default StoredJSON storeJson(String identifier, Object json, long timestamp) { + return storeJson(identifier, new Gson().toJson(json), timestamp); + } + + Optional fetchJSON(String identifier); + + Optional fetchExactJson(String identifier, long timestamp); + + Optional fetchJsonMadeBefore(String identifier, long timestamp); + + Optional fetchJsonMadeAfter(String identifier, long timestamp); + + final class StoredJSON { + public final String json; + public final long timestamp; + + public StoredJSON(String json, long timestamp) { + this.json = json; + this.timestamp = timestamp; + } + } +} diff --git a/Plan/common/src/main/resources/assets/plan/web/js/query.js b/Plan/common/src/main/resources/assets/plan/web/js/query.js index 90a843a72..88b2102da 100644 --- a/Plan/common/src/main/resources/assets/plan/web/js/query.js +++ b/Plan/common/src/main/resources/assets/plan/web/js/query.js @@ -4,6 +4,7 @@ let filterCount = 0; id: "DOM id", options... }*/ +let timestamp = undefined; let filterView = { afterDate: null, afterTime: null, @@ -53,7 +54,7 @@ class MultipleChoiceFilter extends Filter { toObject() { let selected = []; - for (let option of document.querySelector('#' + filter.id + " select").selectedOptions) { + for (let option of document.querySelector('#' + this.id + " select").selectedOptions) { selected.push(option.text); } selected = JSON.stringify(selected); @@ -257,24 +258,50 @@ function setFilterOption( } } +let query = []; + function performQuery() { + for (let filter of filterQuery) { + query.push(filter.toObject()); + } + runQuery(); +} + +function getQueryAddress() { + if (timestamp) return `./v1/query?timestamp=${timestamp}`; + + const encodedQuery = encodeURIComponent(JSON.stringify(query)); + const encodedView = encodeURIComponent(JSON.stringify(filterView)); + return `./v1/query?q=${encodedQuery}&view=${encodedView}`; +} + +function runQuery() { const queryButton = document.querySelector('#query-button'); queryButton.setAttribute('disabled', 'true'); queryButton.classList.add('disabled'); - const query = []; - for (filter of filterQuery) { - query.push(filter.toObject()); - } + document.querySelector('#content .tab').innerHTML = + `
+ +

Loading..

+
`; - const encodedQuery = encodeURIComponent(JSON.stringify(query)); - const encodedView = encodeURIComponent(JSON.stringify(filterView)); - jsonRequest(`./v1/query?q=${encodedQuery}&view=${encodedView}`, function (json, error) { - console.log(filterQuery); - if (json) console.log(json); - if (error) console.error(error); + const navButton = document.querySelector('.navbar-nav .nav-item'); + navButton.insertAdjacentElement('beforebegin', + ``); - renderDataResultScreen(json.data.players.data.length); + jsonRequest(getQueryAddress(), function (json, error) { + if (!json.data) { + window.history.replaceState({}, '', `${location.pathname}?error=${error ? error : 'Query result expired'}`); + location.reload(); + } + + renderDataResultScreen(json.data.players.data.length, json.view ? json.view : {}); + + window.history.replaceState({}, '', `${location.pathname}?timestamp=${json.timestamp}`); $('.player-table').DataTable({ responsive: true, @@ -291,7 +318,11 @@ function performQuery() { }); } -function renderDataResultScreen(resultCount) { +function renderDataResultScreen(resultCount, view) { + const afterDate = filterView.afterDate ? filterView.afterDate : view.afterDate; + const beforeDate = filterView.beforeDate ? filterView.beforeDate : view.beforeDate; + const afterTime = filterView.afterTime ? filterView.afterTime : view.afterTime; + const beforeTime = filterView.beforeTime ? filterView.beforeTime : view.beforeTime; document.querySelector('#content .tab').innerHTML = `
@@ -301,12 +332,12 @@ function renderDataResultScreen(resultCount) {

(matched ${resultCount} players)

-
+
-
- View: ${filterView.afterDate} - ${filterView.beforeDate}
+ View: ${afterDate} - ${beforeDate}
diff --git a/Plan/common/src/main/resources/assets/plan/web/query.html b/Plan/common/src/main/resources/assets/plan/web/query.html index e09ac021e..fafb58f0f 100644 --- a/Plan/common/src/main/resources/assets/plan/web/query.html +++ b/Plan/common/src/main/resources/assets/plan/web/query.html @@ -78,7 +78,7 @@
-
+
@@ -322,22 +322,40 @@ diff --git a/Plan/common/src/test/java/com/djrapitops/plan/storage/database/DatabaseTestComponent.java b/Plan/common/src/test/java/com/djrapitops/plan/storage/database/DatabaseTestComponent.java index 6f7c9a47c..aeedbb063 100644 --- a/Plan/common/src/test/java/com/djrapitops/plan/storage/database/DatabaseTestComponent.java +++ b/Plan/common/src/test/java/com/djrapitops/plan/storage/database/DatabaseTestComponent.java @@ -18,6 +18,7 @@ package com.djrapitops.plan.storage.database; import com.djrapitops.plan.delivery.DeliveryUtilities; import com.djrapitops.plan.identification.ServerInfo; +import com.djrapitops.plan.modules.FiltersModule; import com.djrapitops.plan.settings.ConfigSystem; import com.djrapitops.plan.settings.config.PlanConfig; import com.djrapitops.plan.storage.file.PlanFiles; @@ -34,6 +35,7 @@ import java.nio.file.Path; @Component(modules = { DBSystemModule.class, TestSystemObjectProvidingModule.class, + FiltersModule.class, TestAPFModule.class, PlanPluginModule.class, diff --git a/Plan/common/src/test/java/utilities/dagger/PlanPluginModule.java b/Plan/common/src/test/java/utilities/dagger/PlanPluginModule.java index 6d3bc6d58..6a89ec609 100644 --- a/Plan/common/src/test/java/utilities/dagger/PlanPluginModule.java +++ b/Plan/common/src/test/java/utilities/dagger/PlanPluginModule.java @@ -20,6 +20,8 @@ import com.djrapitops.plan.identification.ServerInfo; import com.djrapitops.plan.identification.ServerServerInfo; import com.djrapitops.plan.settings.BukkitConfigSystem; import com.djrapitops.plan.settings.ConfigSystem; +import com.djrapitops.plan.storage.json.JSONFileStorage; +import com.djrapitops.plan.storage.json.JSONStorage; import dagger.Binds; import dagger.Module; @@ -37,4 +39,7 @@ public interface PlanPluginModule { @Binds ServerInfo bindServerInfo(ServerServerInfo serverServerInfo); + @Binds + JSONStorage bindJSONStorage(JSONFileStorage jsonFileStorage); + } \ No newline at end of file