Made it possible to share query results via url

This commit is contained in:
Risto Lahtela 2021-01-18 10:50:52 +02:00
parent c7ed844c76
commit 7baf1d7556
10 changed files with 332 additions and 32 deletions

View File

@ -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
*/

View File

@ -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<JSONStorage.StoredJSON> 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<FilterQuery> queries = FilterQuery.parse(q);
@ -92,15 +112,19 @@ public class QueryJSONResolver implements Resolver {
Map<String, Object> 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) {

View File

@ -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;
}
}

View File

@ -149,4 +149,8 @@ public class PlanFiles implements SubSystem {
}
return Optional.empty();
}
public Path getJSONStorageDirectory() {
return getDataDirectory().resolve("cached_json");
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<StoredJSON> 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<String> 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<StoredJSON> 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<StoredJSON> fetchJsonMadeBefore(String identifier, long timestamp) {
return fetchJSONWithTimestamp(identifier, timestamp, (timestampMatch, time) -> Long.parseLong(timestampMatch.group(1)) < time);
}
@Override
public Optional<StoredJSON> fetchJsonMadeAfter(String identifier, long timestamp) {
return fetchJSONWithTimestamp(identifier, timestamp, (timestampMatch, time) -> Long.parseLong(timestampMatch.group(1)) > time);
}
private Optional<StoredJSON> fetchJSONWithTimestamp(String identifier, long timestamp, BiPredicate<Matcher, Long> 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();
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<StoredJSON> fetchJSON(String identifier);
Optional<StoredJSON> fetchExactJson(String identifier, long timestamp);
Optional<StoredJSON> fetchJsonMadeBefore(String identifier, long timestamp);
Optional<StoredJSON> 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;
}
}
}

View File

@ -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 =
`<div class="page-loader">
<span class="loader"></span>
<p class="loader-text">Loading..</p>
</div>`;
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',
`<li class="nav-item nav-button"><a class="nav-link" href="./query">
<i class="far fa-fw fa-undo"></i>
<span>Make another query</span>
</a></li>`);
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 =
`<div class="container-fluid mt-4">
<!-- Page Heading -->
@ -301,12 +332,12 @@ function renderDataResultScreen(resultCount) {
<p class="mb-0 text-gray-800">(matched ${resultCount} players)</p>
</div>
<div class="row">
<div class="col-xs-12 col-sm-12 col-lg-11">
<div class="col-xs-12 col-sm-12 col-lg-12">
<div class="card shadow mb-4">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold col-black" title=" ${filterView.afterDate} ${filterView.afterTime} - ${filterView.beforeDate} ${filterView.beforeTime}"><i
<h6 class="m-0 font-weight-bold col-black" title=" ${afterDate} ${afterTime} - ${beforeDate} ${beforeTime}"><i
class="fas fa-fw fa-users col-black"></i>
View: ${filterView.afterDate} - ${filterView.beforeDate}</h6>
View: ${afterDate} - ${beforeDate}</h6>
</div>
<div class="table-responsive">
<table class="table table-bordered table-striped table-hover player-table dataTable">

View File

@ -78,7 +78,7 @@
<div class="row">
<!-- Card -->
<div class="col-xs-12 col-sm-12 col-lg-11">
<div class="col-xs-12 col-sm-12 col-lg-12">
<div class="card shadow mb-4">
<div class="card-body" id="data_player_info">
<label class="mt-2 mb-0" for="viewFromDateField">Show a view</label>
@ -322,22 +322,40 @@
<script id="mainScript">
const filters = [];
jsonRequest("./v1/filters", function (json, error) {
filters.push(...json.filters);
filterView = json.view;
if (location.search.includes("error=")) {
const placeBefore = document.querySelector('.tab .row div');
placeBefore.insertAdjacentElement('beforebegin',
`<alert class="alert alert-danger alert-dismissible show">
${new URLSearchParams(location.search).get("error")}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</alert>`);
}
document.getElementById('viewFromDateField').setAttribute('placeholder', json.view.afterDate);
document.getElementById('viewFromTimeField').setAttribute('placeholder', json.view.afterTime);
document.getElementById('viewToDateField').setAttribute('placeholder', json.view.beforeDate);
document.getElementById('viewToTimeField').setAttribute('placeholder', json.view.beforeTime);
if (location.search.includes('timestamp=')) {
const parameters = new URLSearchParams(location.search);
timestamp = parameters.get('timestamp');
runQuery();
} else {
jsonRequest("./v1/filters", function (json, error) {
filters.push(...json.filters);
let filterElements = '';
for (let i = 0; i < filters.length; i++) {
filterElements += createFilterSelector('#filters', i, filters[i]);
}
document.getElementById('filter-dropdown').innerHTML = filterElements;
});
filterView = json.view;
document.getElementById('viewFromDateField').setAttribute('placeholder', json.view.afterDate);
document.getElementById('viewFromTimeField').setAttribute('placeholder', json.view.afterTime);
document.getElementById('viewToDateField').setAttribute('placeholder', json.view.beforeDate);
document.getElementById('viewToTimeField').setAttribute('placeholder', json.view.beforeTime);
let filterElements = '';
for (let i = 0; i < filters.length; i++) {
filterElements += createFilterSelector('#filters', i, filters[i]);
}
document.getElementById('filter-dropdown').innerHTML = filterElements;
});
}
</script>
</body>

View File

@ -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,

View File

@ -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);
}