Add web frontend and Docker packaging
Frontend is a single page served from classpath resources with a JSON API alongside it. Kept separate from the MCP tools because the consumers differ: a model reads formatted text, a browser needs structures. Only the database is shared. The page has three views - catalog with edition details, copies, detected facts with evidence and a filterable file list; script search with FTS5 snippets and a viewer; and a format breakdown. No build step, no CDN, no dependencies; light and dark follow the system. New `serve` command puts the frontend and MCP on one port, one process and one database connection, which is also what the container runs. `mcp` alone still works for a headless setup. Docker is a two-stage build: JDK plus the Rex-EMoolator submodule to compile, JRE for runtime. installDist rather than build, so check - and with it verifyCoreVersion - is skipped and git is not needed in the image. The pinned :core tag is extracted from gradle.properties at build time and passed in by the entrypoint, otherwise copy.core_version would record "nieznana" and derived-artifact invalidation would stop working. Properties go through JAVA_OPTS because Gradle's launcher treats everything after the script name as application arguments. Compose mounts the collection read-only and keeps the database and script cache in a named volume. The port is bound to loopback. Not verified: the image itself does not build here, the Docker daemon is not running on this machine. The launcher, JAVA_OPTS handling and resource packaging were tested against the installDist output directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
393bcde6a8
commit
02988297ec
@@ -0,0 +1,12 @@
|
|||||||
|
# submoduł vendor/ MUSI zostać — composite build kompiluje z niego :core
|
||||||
|
.git
|
||||||
|
.gitmodules
|
||||||
|
.gradle
|
||||||
|
.idea
|
||||||
|
build
|
||||||
|
out
|
||||||
|
data
|
||||||
|
collection
|
||||||
|
vendor/Rex-EMoolator/.git
|
||||||
|
vendor/Rex-EMoolator/build
|
||||||
|
vendor/Rex-EMoolator/.gradle
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Build wymaga submodułu vendor/Rex-EMoolator — composite build kompiluje :core
|
||||||
|
# ze źródeł. Jeśli katalog jest pusty, settings.gradle przerwie z komunikatem.
|
||||||
|
FROM eclipse-temurin:21-jdk AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# najpierw sam opis buildu: zmiana w kodzie nie unieważnia pobranych zależności
|
||||||
|
COPY gradlew settings.gradle build.gradle gradle.properties ./
|
||||||
|
COPY gradle ./gradle
|
||||||
|
COPY vendor ./vendor
|
||||||
|
RUN ./gradlew --no-daemon dependencies --configuration runtimeClasspath > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
# installDist zamiast build: pomija check, a więc i verifyCoreVersion, które
|
||||||
|
# potrzebowałoby gita w obrazie. Wersję i tak przypina submoduł.
|
||||||
|
RUN ./gradlew --no-daemon installDist \
|
||||||
|
&& grep '^coreVersion=' gradle.properties | cut -d= -f2 > /src/core-version
|
||||||
|
|
||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# obrazy płyt montowane z zewnątrz, tylko do odczytu; katalog danych to wolumen
|
||||||
|
VOLUME ["/data"]
|
||||||
|
ENV CATALOG_DATA=/data \
|
||||||
|
JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8"
|
||||||
|
|
||||||
|
COPY --from=build /src/build/install/rex-catalog /app
|
||||||
|
COPY --from=build /src/core-version /app/core-version
|
||||||
|
COPY docker-entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh \
|
||||||
|
&& useradd --system --uid 10001 --create-home katalog \
|
||||||
|
&& mkdir -p /data /media \
|
||||||
|
&& chown katalog:katalog /data
|
||||||
|
USER katalog
|
||||||
|
|
||||||
|
EXPOSE 8765
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
# domyślnie wstajemy jako serwer; front i MCP dzielą port
|
||||||
|
CMD ["serve", "--host", "0.0.0.0"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
katalog:
|
||||||
|
build: .
|
||||||
|
image: rex-catalog:latest
|
||||||
|
ports:
|
||||||
|
# front pod /, MCP pod /mcp — jeden port, jeden proces
|
||||||
|
- "127.0.0.1:8765:8765"
|
||||||
|
volumes:
|
||||||
|
# kolekcja tylko do odczytu: katalog niczego w niej nie zmienia,
|
||||||
|
# a obrazy płyt są nieodtwarzalne
|
||||||
|
- ${COLLECTION:-./collection}:/media:ro
|
||||||
|
# baza i cache odszyfrowanych skryptów — jedyny stan wart trzymania
|
||||||
|
- catalog-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
catalog-data:
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Launcher generowany przez Gradle traktuje wszystko po nazwie skryptu jako
|
||||||
|
# argumenty aplikacji, więc właściwości JVM muszą iść przez JAVA_OPTS.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# wersja :core jest przypięta submodułem w czasie budowania obrazu; bez niej
|
||||||
|
# copy.core_version zapisałoby "nieznana" i przestałoby działać unieważnianie
|
||||||
|
# cache'u pochodnych po bumpie emulatora
|
||||||
|
CORE_VERSION="${CORE_VERSION:-$(cat /app/core-version 2>/dev/null || echo nieznana)}"
|
||||||
|
|
||||||
|
JAVA_OPTS="-Dfile.encoding=UTF-8 -Dcatalog.data=${CATALOG_DATA:-/data} -Dcatalog.coreVersion=${CORE_VERSION} ${JAVA_OPTS}"
|
||||||
|
export JAVA_OPTS
|
||||||
|
|
||||||
|
exec /app/bin/rex-catalog "$@"
|
||||||
@@ -6,10 +6,14 @@ import pl.genschu.rexcatalog.db.Database;
|
|||||||
import pl.genschu.rexcatalog.ingest.Ingestor;
|
import pl.genschu.rexcatalog.ingest.Ingestor;
|
||||||
import pl.genschu.rexcatalog.ingest.MetadataDetector;
|
import pl.genschu.rexcatalog.ingest.MetadataDetector;
|
||||||
import pl.genschu.rexcatalog.mcp.McpServer;
|
import pl.genschu.rexcatalog.mcp.McpServer;
|
||||||
|
import pl.genschu.rexcatalog.web.WebServer;
|
||||||
import pl.genschu.rexcatalog.script.ScriptDecoder;
|
import pl.genschu.rexcatalog.script.ScriptDecoder;
|
||||||
import pl.genschu.rexcatalog.script.ScriptSearch;
|
import pl.genschu.rexcatalog.script.ScriptSearch;
|
||||||
|
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
@@ -17,6 +21,7 @@ import java.sql.Statement;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI katalogu. Krok 1: indeksowanie obrazów do SQLite.
|
* CLI katalogu. Krok 1: indeksowanie obrazów do SQLite.
|
||||||
@@ -46,6 +51,7 @@ public final class Main {
|
|||||||
case "set" -> set(dataDir, args);
|
case "set" -> set(dataDir, args);
|
||||||
case "lang" -> lang(dataDir, args);
|
case "lang" -> lang(dataDir, args);
|
||||||
case "mcp" -> mcp(dataDir, args);
|
case "mcp" -> mcp(dataDir, args);
|
||||||
|
case "serve" -> serve(dataDir, args);
|
||||||
case "find" -> find(dataDir, args);
|
case "find" -> find(dataDir, args);
|
||||||
case "cat" -> cat(dataDir, args);
|
case "cat" -> cat(dataDir, args);
|
||||||
default -> usage();
|
default -> usage();
|
||||||
@@ -314,6 +320,28 @@ public final class Main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Front i MCP na jednym porcie — jeden proces, jeden kontener, jedno połączenie z bazą. */
|
||||||
|
private static void serve(Path dataDir, String[] args) throws Exception {
|
||||||
|
String host = option(args, "--host", "127.0.0.1");
|
||||||
|
int port = Integer.parseInt(option(args, "--port", "8765"));
|
||||||
|
|
||||||
|
try (Database db = Database.open(dataDir)) {
|
||||||
|
McpServer mcp = new McpServer(db, dataDir);
|
||||||
|
HttpServer http = HttpServer.create(new InetSocketAddress(host, port), 0);
|
||||||
|
mcp.register(http);
|
||||||
|
new WebServer(db, dataDir, mcp.lock()).register(http);
|
||||||
|
http.setExecutor(Executors.newFixedThreadPool(4));
|
||||||
|
http.start();
|
||||||
|
|
||||||
|
System.out.printf("Front: http://%s:%d/%n", host, port);
|
||||||
|
System.out.printf("MCP: http://%s:%d/mcp%n", host, port);
|
||||||
|
System.out.println("Zatrzymanie: Ctrl+C");
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> http.stop(0)));
|
||||||
|
Thread.currentThread().join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String option(String[] args, String name, String fallback) {
|
private static String option(String[] args, String name, String fallback) {
|
||||||
for (int i = 1; i < args.length - 1; i++) {
|
for (int i = 1; i < args.length - 1; i++) {
|
||||||
if (args[i].equals(name)) {
|
if (args[i].equals(name)) {
|
||||||
@@ -483,6 +511,7 @@ public final class Main {
|
|||||||
cat <ścieżka|sha1> wypisz odszyfrowany skrypt
|
cat <ścieżka|sha1> wypisz odszyfrowany skrypt
|
||||||
|
|
||||||
mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765)
|
mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765)
|
||||||
|
serve [--host H] [--port P] front i MCP na jednym porcie
|
||||||
|
|
||||||
Baza: -Dcatalog.data=<katalog> (domyślnie ./data)
|
Baza: -Dcatalog.data=<katalog> (domyślnie ./data)
|
||||||
""");
|
""");
|
||||||
|
|||||||
@@ -52,12 +52,25 @@ public final class McpServer {
|
|||||||
|
|
||||||
public void start(String host, int port) throws IOException {
|
public void start(String host, int port) throws IOException {
|
||||||
http = HttpServer.create(new InetSocketAddress(host, port), 0);
|
http = HttpServer.create(new InetSocketAddress(host, port), 0);
|
||||||
http.createContext("/mcp", this::handle);
|
register(http);
|
||||||
// pula wątków, ale dostęp do bazy i tak serializujemy — patrz dispatch()
|
// pula wątków, ale dostęp do bazy i tak serializujemy — patrz toolsCall()
|
||||||
http.setExecutor(Executors.newFixedThreadPool(4));
|
http.setExecutor(Executors.newFixedThreadPool(4));
|
||||||
http.start();
|
http.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Podpina endpoint MCP do cudzego serwera — front i MCP dzielą jeden port. */
|
||||||
|
public void register(HttpServer server) {
|
||||||
|
server.createContext("/mcp", this::handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blokada serializująca dostęp do bazy. Front musi używać tej samej, bo obie
|
||||||
|
* warstwy siedzą na jednym połączeniu sqlite-jdbc.
|
||||||
|
*/
|
||||||
|
public Object lock() {
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
public void stop() {
|
public void stop() {
|
||||||
if (http != null) {
|
if (http != null) {
|
||||||
http.stop(0);
|
http.stop(0);
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package pl.genschu.rexcatalog.web;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import pl.genschu.rexcatalog.db.Database;
|
||||||
|
import pl.genschu.rexcatalog.script.ScriptSearch;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przeglądarkowy front katalogu: statyczna strona z zasobów oraz API JSON.
|
||||||
|
*
|
||||||
|
* <p>Osobno od narzędzi MCP, bo odbiorca jest inny — model czyta tekst, przeglądarka
|
||||||
|
* potrzebuje struktur. Wspólna jest baza, nie warstwa prezentacji.
|
||||||
|
*/
|
||||||
|
public final class WebServer {
|
||||||
|
|
||||||
|
private final Database db;
|
||||||
|
private final ScriptSearch search;
|
||||||
|
private final Object lock;
|
||||||
|
|
||||||
|
public WebServer(Database db, Path dataDir, Object lock) {
|
||||||
|
this.db = db;
|
||||||
|
this.search = new ScriptSearch(db, dataDir);
|
||||||
|
this.lock = lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void register(HttpServer http) {
|
||||||
|
http.createContext("/api/", this::handleApi);
|
||||||
|
http.createContext("/", this::handleStatic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- statyczne ----------
|
||||||
|
|
||||||
|
private void handleStatic(HttpExchange exchange) throws IOException {
|
||||||
|
try {
|
||||||
|
String path = exchange.getRequestURI().getPath();
|
||||||
|
if (path.equals("/") || path.isEmpty()) {
|
||||||
|
path = "/index.html";
|
||||||
|
}
|
||||||
|
// zasoby są wbudowane w jar, ale ścieżka i tak przychodzi z sieci
|
||||||
|
if (path.contains("..")) {
|
||||||
|
respond(exchange, 400, "text/plain", "Nieprawidłowa ścieżka".getBytes(UTF8));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (InputStream in = WebServer.class.getResourceAsStream("/web" + path)) {
|
||||||
|
if (in == null) {
|
||||||
|
respond(exchange, 404, "text/plain", "Nie ma takiej strony".getBytes(UTF8));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(exchange, 200, contentType(path), in.readAllBytes());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String contentType(String path) {
|
||||||
|
if (path.endsWith(".html")) {
|
||||||
|
return "text/html";
|
||||||
|
}
|
||||||
|
if (path.endsWith(".css")) {
|
||||||
|
return "text/css";
|
||||||
|
}
|
||||||
|
if (path.endsWith(".js")) {
|
||||||
|
return "application/javascript";
|
||||||
|
}
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API ----------
|
||||||
|
|
||||||
|
private void handleApi(HttpExchange exchange) throws IOException {
|
||||||
|
try {
|
||||||
|
String path = exchange.getRequestURI().getPath().substring("/api".length());
|
||||||
|
Map<String, String> query = parseQuery(exchange.getRequestURI().getRawQuery());
|
||||||
|
|
||||||
|
Object payload;
|
||||||
|
synchronized (lock) {
|
||||||
|
payload = route(path, query);
|
||||||
|
}
|
||||||
|
if (payload == null) {
|
||||||
|
respond(exchange, 404, "application/json",
|
||||||
|
"{\"error\":\"nieznany zasób\"}".getBytes(UTF8));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(exchange, 200, "application/json", payload.toString().getBytes(UTF8));
|
||||||
|
} catch (Exception e) {
|
||||||
|
JsonObject error = new JsonObject();
|
||||||
|
error.addProperty("error", e.getMessage() == null ? e.toString() : e.getMessage());
|
||||||
|
respond(exchange, 500, "application/json", error.toString().getBytes(UTF8));
|
||||||
|
} finally {
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object route(String path, Map<String, String> query) throws Exception {
|
||||||
|
if (path.equals("/stats")) {
|
||||||
|
return stats();
|
||||||
|
}
|
||||||
|
if (path.equals("/titles")) {
|
||||||
|
return titles();
|
||||||
|
}
|
||||||
|
if (path.equals("/search")) {
|
||||||
|
return searchScripts(query.getOrDefault("q", ""),
|
||||||
|
parseInt(query.get("limit"), 30));
|
||||||
|
}
|
||||||
|
if (path.startsWith("/editions/")) {
|
||||||
|
String rest = path.substring("/editions/".length());
|
||||||
|
if (rest.endsWith("/files")) {
|
||||||
|
int id = parseInt(rest.substring(0, rest.length() - "/files".length()), -1);
|
||||||
|
return files(id, query.get("format"), query.get("q"),
|
||||||
|
parseInt(query.get("limit"), 300));
|
||||||
|
}
|
||||||
|
return edition(parseInt(rest, -1));
|
||||||
|
}
|
||||||
|
if (path.startsWith("/scripts/")) {
|
||||||
|
return script(path.substring("/scripts/".length()));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject stats() throws Exception {
|
||||||
|
JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("copies", scalar("SELECT COUNT(*) FROM copy"));
|
||||||
|
out.addProperty("titles", scalar("SELECT COUNT(*) FROM title"));
|
||||||
|
out.addProperty("editions", scalar("SELECT COUNT(*) FROM edition"));
|
||||||
|
out.addProperty("files", scalar("SELECT COUNT(*) FROM file"));
|
||||||
|
out.addProperty("blobs", scalar("SELECT COUNT(*) FROM blob"));
|
||||||
|
out.addProperty("scripts", scalar("SELECT COUNT(*) FROM script_fts"));
|
||||||
|
out.addProperty("bytes", scalar("SELECT COALESCE(SUM(size), 0) FROM blob"));
|
||||||
|
|
||||||
|
JsonArray formats = new JsonArray();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement(
|
||||||
|
"SELECT format, COUNT(*) n, SUM(size) bytes FROM blob "
|
||||||
|
+ "GROUP BY format ORDER BY n DESC LIMIT 20");
|
||||||
|
ResultSet rs = st.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("format", rs.getString("format"));
|
||||||
|
row.addProperty("n", rs.getInt("n"));
|
||||||
|
row.addProperty("bytes", rs.getLong("bytes"));
|
||||||
|
formats.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add("formats", formats);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonArray titles() throws Exception {
|
||||||
|
JsonArray out = new JsonArray();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||||
|
SELECT e.id AS edition_id, t.name AS title, t.series, e.label,
|
||||||
|
COALESCE(e.release_date_override, (
|
||||||
|
SELECT d.value FROM game_root g
|
||||||
|
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
|
||||||
|
WHERE g.edition_id = e.id AND d.field = 'release_date' LIMIT 1)) AS release_date,
|
||||||
|
COALESCE(e.engine_override, (
|
||||||
|
SELECT g.engine_detected FROM game_root g
|
||||||
|
WHERE g.edition_id = e.id LIMIT 1)) AS engine,
|
||||||
|
(SELECT GROUP_CONCAT(lang, ' ') FROM edition_language
|
||||||
|
WHERE edition_id = e.id) AS langs,
|
||||||
|
(SELECT COUNT(*) FROM game_root WHERE edition_id = e.id) AS copies
|
||||||
|
FROM edition e LEFT JOIN title t ON t.id = e.title_id
|
||||||
|
ORDER BY t.series IS NULL, t.series, t.name, release_date
|
||||||
|
""");
|
||||||
|
ResultSet rs = st.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("edition_id", rs.getInt("edition_id"));
|
||||||
|
row.addProperty("title", rs.getString("title"));
|
||||||
|
row.addProperty("series", rs.getString("series"));
|
||||||
|
row.addProperty("label", rs.getString("label"));
|
||||||
|
row.addProperty("release_date", rs.getString("release_date"));
|
||||||
|
row.addProperty("engine", rs.getString("engine"));
|
||||||
|
row.addProperty("langs", rs.getString("langs"));
|
||||||
|
row.addProperty("copies", rs.getInt("copies"));
|
||||||
|
out.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject edition(int id) throws Exception {
|
||||||
|
JsonObject out = new JsonObject();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||||
|
SELECT e.id, t.name AS title, t.series, t.publisher, e.label, e.fingerprint,
|
||||||
|
e.dll_sha1, e.app_def_sha1, e.distributor, e.notes,
|
||||||
|
e.engine_override, e.engine_version_override,
|
||||||
|
e.compiler_override, e.release_date_override
|
||||||
|
FROM edition e LEFT JOIN title t ON t.id = e.title_id WHERE e.id = ?
|
||||||
|
""")) {
|
||||||
|
st.setInt(1, id);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
if (!rs.next()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String column : List.of("title", "series", "publisher", "label",
|
||||||
|
"fingerprint", "dll_sha1", "app_def_sha1", "distributor", "notes",
|
||||||
|
"engine_override", "engine_version_override", "compiler_override",
|
||||||
|
"release_date_override")) {
|
||||||
|
out.addProperty(column, rs.getString(column));
|
||||||
|
}
|
||||||
|
out.addProperty("edition_id", rs.getInt("id"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray languages = new JsonArray();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement(
|
||||||
|
"SELECT lang, role FROM edition_language WHERE edition_id = ? ORDER BY lang, role")) {
|
||||||
|
st.setInt(1, id);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("lang", rs.getString("lang"));
|
||||||
|
row.addProperty("role", rs.getString("role"));
|
||||||
|
languages.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add("languages", languages);
|
||||||
|
|
||||||
|
JsonArray copies = new JsonArray();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||||
|
SELECT c.id, c.display_name, c.path, c.container, c.fs_type, c.status,
|
||||||
|
c.status_note, c.file_count, c.size, c.sha256, c.source_kind,
|
||||||
|
c.source_url, c.source_ref, c.rip_tool, c.acquired_at, c.verified,
|
||||||
|
c.notes, g.root_path
|
||||||
|
FROM game_root g JOIN copy c ON c.id = g.copy_id
|
||||||
|
WHERE g.edition_id = ? ORDER BY c.display_name
|
||||||
|
""")) {
|
||||||
|
st.setInt(1, id);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
for (String column : List.of("display_name", "path", "container", "fs_type",
|
||||||
|
"status", "status_note", "sha256", "source_kind", "source_url",
|
||||||
|
"source_ref", "rip_tool", "acquired_at", "notes", "root_path")) {
|
||||||
|
row.addProperty(column, rs.getString(column));
|
||||||
|
}
|
||||||
|
row.addProperty("copy_id", rs.getInt("id"));
|
||||||
|
row.addProperty("file_count", rs.getInt("file_count"));
|
||||||
|
row.addProperty("size", rs.getLong("size"));
|
||||||
|
row.addProperty("verified", rs.getInt("verified") == 1);
|
||||||
|
copies.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add("copies", copies);
|
||||||
|
|
||||||
|
JsonArray facts = new JsonArray();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement("""
|
||||||
|
SELECT DISTINCT d.field, d.value, d.confidence, d.evidence, d.detector
|
||||||
|
FROM game_root g
|
||||||
|
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
|
||||||
|
WHERE g.edition_id = ? ORDER BY d.detector, d.field
|
||||||
|
""")) {
|
||||||
|
st.setInt(1, id);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("field", rs.getString("field"));
|
||||||
|
row.addProperty("value", rs.getString("value"));
|
||||||
|
row.addProperty("confidence", rs.getDouble("confidence"));
|
||||||
|
row.addProperty("evidence", rs.getString("evidence"));
|
||||||
|
row.addProperty("detector", rs.getString("detector"));
|
||||||
|
facts.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add("facts", facts);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonArray files(int editionId, String format, String pathContains, int limit)
|
||||||
|
throws Exception {
|
||||||
|
StringBuilder sql = new StringBuilder("""
|
||||||
|
SELECT DISTINCT COALESCE(f.path_raw, f.path) AS path, b.format, b.size,
|
||||||
|
b.encrypted, f.blob_sha1,
|
||||||
|
(SELECT 1 FROM artifact a WHERE a.blob_sha1 = f.blob_sha1
|
||||||
|
AND a.kind = 'script') AS decoded
|
||||||
|
FROM game_root g
|
||||||
|
JOIN file f ON f.copy_id = g.copy_id
|
||||||
|
JOIN blob b ON b.sha1 = f.blob_sha1
|
||||||
|
WHERE g.edition_id = ?
|
||||||
|
""");
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
params.add(editionId);
|
||||||
|
if (format != null && !format.isBlank()) {
|
||||||
|
sql.append(" AND b.format = ?\n");
|
||||||
|
params.add(format.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
if (pathContains != null && !pathContains.isBlank()) {
|
||||||
|
sql.append(" AND f.path LIKE ?\n");
|
||||||
|
params.add("%" + pathContains.toLowerCase(Locale.ROOT) + "%");
|
||||||
|
}
|
||||||
|
sql.append("ORDER BY path LIMIT ?");
|
||||||
|
params.add(Math.min(limit, 2000));
|
||||||
|
|
||||||
|
JsonArray out = new JsonArray();
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement(sql.toString())) {
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
st.setObject(i + 1, params.get(i));
|
||||||
|
}
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("path", rs.getString("path"));
|
||||||
|
row.addProperty("format", rs.getString("format"));
|
||||||
|
row.addProperty("size", rs.getLong("size"));
|
||||||
|
row.addProperty("encrypted", rs.getInt("encrypted") == 1);
|
||||||
|
row.addProperty("sha1", rs.getString("blob_sha1"));
|
||||||
|
row.addProperty("decoded", rs.getInt("decoded") == 1);
|
||||||
|
out.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject searchScripts(String query, int limit) throws Exception {
|
||||||
|
JsonObject out = new JsonObject();
|
||||||
|
if (query.isBlank()) {
|
||||||
|
out.addProperty("query", "");
|
||||||
|
out.add("hits", new JsonArray());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
ScriptSearch.Result result = search.find(query, Math.min(limit, 200));
|
||||||
|
out.addProperty("query", result.effectiveQuery());
|
||||||
|
out.addProperty("quoted", result.quoted());
|
||||||
|
|
||||||
|
JsonArray hits = new JsonArray();
|
||||||
|
for (ScriptSearch.Hit hit : result.hits()) {
|
||||||
|
JsonObject row = new JsonObject();
|
||||||
|
row.addProperty("sha1", hit.sha1());
|
||||||
|
row.addProperty("snippet", hit.snippet());
|
||||||
|
row.add("paths", toArray(hit.paths()));
|
||||||
|
row.add("games", toArray(hit.games()));
|
||||||
|
hits.add(row);
|
||||||
|
}
|
||||||
|
out.add("hits", hits);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject script(String sha1) throws Exception {
|
||||||
|
String body = search.body(sha1);
|
||||||
|
if (body == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonObject out = new JsonObject();
|
||||||
|
out.addProperty("sha1", sha1);
|
||||||
|
out.addProperty("body", body);
|
||||||
|
out.add("paths", toArray(search.pathsFor(sha1)));
|
||||||
|
out.add("games", toArray(search.gamesFor(sha1)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- pomocnicze ----------
|
||||||
|
|
||||||
|
private static final java.nio.charset.Charset UTF8 = StandardCharsets.UTF_8;
|
||||||
|
|
||||||
|
private static JsonArray toArray(List<String> values) {
|
||||||
|
JsonArray array = new JsonArray();
|
||||||
|
values.forEach(array::add);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long scalar(String sql) throws Exception {
|
||||||
|
try (PreparedStatement st = db.connection().prepareStatement(sql);
|
||||||
|
ResultSet rs = st.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getLong(1) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseInt(String value, int fallback) {
|
||||||
|
try {
|
||||||
|
return value == null ? fallback : Integer.parseInt(value.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> parseQuery(String raw) {
|
||||||
|
Map<String, String> out = new LinkedHashMap<>();
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (String pair : raw.split("&")) {
|
||||||
|
int eq = pair.indexOf('=');
|
||||||
|
if (eq < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.put(URLDecoder.decode(pair.substring(0, eq), UTF8),
|
||||||
|
URLDecoder.decode(pair.substring(eq + 1), UTF8));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void respond(HttpExchange exchange, int status, String contentType, byte[] body)
|
||||||
|
throws IOException {
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8");
|
||||||
|
exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
|
||||||
|
if (body.length > 0) {
|
||||||
|
exchange.getResponseBody().write(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>rex-catalog</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f5f2; --panel: #fff; --line: #ddd8cf; --ink: #2b2822; --dim: #7a7368;
|
||||||
|
--accent: #8a5a2b; --hl: #f2e6c9; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #1a1815; --panel: #232019; --line: #3a352c; --ink: #e8e2d6; --dim: #948b7c;
|
||||||
|
--accent: #d3a05e; --hl: #3d3323;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--ink);
|
||||||
|
font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--line); background: var(--panel);
|
||||||
|
display: flex; gap: 20px; align-items: baseline; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h1 { margin: 0; font-size: 17px; letter-spacing: .02em; }
|
||||||
|
h1 span { color: var(--accent); }
|
||||||
|
#stats { color: var(--dim); font-size: 12.5px; }
|
||||||
|
nav { margin-left: auto; display: flex; gap: 4px; }
|
||||||
|
nav button {
|
||||||
|
border: 1px solid var(--line); background: transparent; color: var(--ink);
|
||||||
|
padding: 5px 12px; border-radius: 5px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
nav button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
main { padding: 18px 20px; max-width: 1500px; }
|
||||||
|
.cols { display: grid; grid-template-columns: minmax(320px, 460px) 1fr; gap: 18px; align-items: start; }
|
||||||
|
@media (max-width: 900px) { .cols { grid-template-columns: 1fr; } }
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
||||||
|
padding: 14px 16px; margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||||
|
th {
|
||||||
|
text-align: left; font-weight: 600; color: var(--dim); font-size: 11.5px;
|
||||||
|
text-transform: uppercase; letter-spacing: .04em;
|
||||||
|
padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap;
|
||||||
|
}
|
||||||
|
td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||||
|
tbody tr { cursor: pointer; }
|
||||||
|
tbody tr:hover { background: var(--hl); }
|
||||||
|
tbody tr.on { background: var(--hl); box-shadow: inset 3px 0 0 var(--accent); }
|
||||||
|
.series { color: var(--dim); font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.label { color: var(--dim); font-size: 12.5px; }
|
||||||
|
.mono { font-family: var(--mono); font-size: 12px; }
|
||||||
|
.dim { color: var(--dim); }
|
||||||
|
input[type=search], input[type=text], select {
|
||||||
|
width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px;
|
||||||
|
background: var(--bg); color: var(--ink); font-size: 13px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.row { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.row > * { flex: 1; }
|
||||||
|
.row > .narrow { flex: 0 0 130px; }
|
||||||
|
dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; margin: 0; font-size: 13px; }
|
||||||
|
dt { color: var(--dim); white-space: nowrap; }
|
||||||
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
pre {
|
||||||
|
font-family: var(--mono); font-size: 12px; line-height: 1.45; margin: 0;
|
||||||
|
white-space: pre-wrap; overflow-wrap: anywhere; max-height: 62vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
mark { background: var(--accent); color: #fff; border-radius: 2px; padding: 0 1px; }
|
||||||
|
.snip { font-family: var(--mono); font-size: 11.5px; color: var(--dim); margin-top: 3px; }
|
||||||
|
.pill {
|
||||||
|
display: inline-block; padding: 1px 7px; border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; font-size: 11px; color: var(--dim); margin-right: 4px;
|
||||||
|
}
|
||||||
|
.bar { height: 6px; background: var(--hl); border-radius: 3px; overflow: hidden; }
|
||||||
|
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.empty { color: var(--dim); padding: 20px 0; text-align: center; }
|
||||||
|
h2 { font-size: 14px; margin: 0 0 10px; }
|
||||||
|
h3 { font-size: 12px; margin: 16px 0 6px; color: var(--dim);
|
||||||
|
text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>rex<span>·</span>catalog</h1>
|
||||||
|
<div id="stats">wczytuję…</div>
|
||||||
|
<nav>
|
||||||
|
<button data-view="katalog" class="on">Katalog</button>
|
||||||
|
<button data-view="skrypty">Skrypty</button>
|
||||||
|
<button data-view="formaty">Formaty</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="katalog" class="cols">
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<input type="search" id="filtr" placeholder="Filtruj tytuły…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="card"><table>
|
||||||
|
<thead><tr><th>Tytuł</th><th>Silnik</th><th>Data</th><th>Kopie</th></tr></thead>
|
||||||
|
<tbody id="tytuly"></tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
<div id="szczegoly"><div class="card empty">Wybierz tytuł z listy.</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="skrypty" hidden class="cols">
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<input type="search" id="szukaj" placeholder="Szukaj w skryptach, np. CANVAS_OBSERVER"
|
||||||
|
autocomplete="off">
|
||||||
|
<div class="dim" style="margin-top:8px;font-size:12px">
|
||||||
|
Składnia FTS5. Identyfikatory z podkreśleniem są jednym tokenem;
|
||||||
|
użyj <span class="mono">*</span> do przedrostka.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="wyniki"><div class="empty">Wpisz zapytanie.</div></div>
|
||||||
|
</div>
|
||||||
|
<div id="podglad"><div class="card empty">Wybierz trafienie, żeby zobaczyć skrypt.</div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="formaty" hidden>
|
||||||
|
<div class="card" style="max-width:620px">
|
||||||
|
<h2>Rozkład formatów</h2>
|
||||||
|
<table><thead><tr><th>Format</th><th>Plików</th><th>Rozmiar</th><th></th></tr></thead>
|
||||||
|
<tbody id="tabformaty"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const esc = s => String(s ?? '').replace(/[&<>"]/g, c =>
|
||||||
|
({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
const mb = b => b >= 1048576 ? (b/1048576).toFixed(1)+' MB'
|
||||||
|
: b >= 1024 ? (b/1024).toFixed(1)+' kB' : b+' B';
|
||||||
|
const get = async u => { const r = await fetch(u); if (!r.ok) throw new Error(await r.text());
|
||||||
|
return r.json(); };
|
||||||
|
|
||||||
|
let tytuly = [];
|
||||||
|
|
||||||
|
// ---- nawigacja ----
|
||||||
|
document.querySelectorAll('nav button').forEach(b => b.onclick = () => {
|
||||||
|
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('on', x === b));
|
||||||
|
['katalog','skrypty','formaty'].forEach(v => $('#'+v).hidden = v !== b.dataset.view);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- katalog ----
|
||||||
|
function rysujTytuly(filtr = '') {
|
||||||
|
const f = filtr.toLowerCase();
|
||||||
|
const widoczne = tytuly.filter(t =>
|
||||||
|
!f || (t.title||'').toLowerCase().includes(f) || (t.label||'').toLowerCase().includes(f)
|
||||||
|
|| (t.engine||'').toLowerCase().includes(f) || (t.series||'').toLowerCase().includes(f));
|
||||||
|
|
||||||
|
if (!widoczne.length) { $('#tytuly').innerHTML =
|
||||||
|
'<tr><td colspan="4" class="empty">Nic nie pasuje.</td></tr>'; return; }
|
||||||
|
|
||||||
|
$('#tytuly').innerHTML = widoczne.map(t => `
|
||||||
|
<tr data-id="${t.edition_id}">
|
||||||
|
<td>${t.series ? `<div class="series">${esc(t.series)}</div>` : ''}
|
||||||
|
${esc(t.title)}
|
||||||
|
${t.label ? `<div class="label">${esc(t.label)}</div>` : ''}</td>
|
||||||
|
<td>${esc(t.engine || '—')}</td>
|
||||||
|
<td class="mono">${esc(t.release_date || '—')}</td>
|
||||||
|
<td>${t.copies}</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
$('#tytuly').querySelectorAll('tr[data-id]').forEach(tr => tr.onclick = () => {
|
||||||
|
$('#tytuly').querySelectorAll('tr').forEach(x => x.classList.remove('on'));
|
||||||
|
tr.classList.add('on');
|
||||||
|
pokazWydanie(tr.dataset.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pokazWydanie(id) {
|
||||||
|
$('#szczegoly').innerHTML = '<div class="card empty">Wczytuję…</div>';
|
||||||
|
const e = await get('/api/editions/' + id);
|
||||||
|
const pliki = await get(`/api/editions/${id}/files?limit=400`);
|
||||||
|
|
||||||
|
const jezyki = e.languages.length
|
||||||
|
? e.languages.map(l => `<span class="pill">${esc(l.lang)} · ${esc(l.role)}</span>`).join('')
|
||||||
|
: '<span class="dim">nie uzupełniono</span>';
|
||||||
|
|
||||||
|
const pole = (k, v) => v ? `<dt>${k}</dt><dd>${esc(v)}</dd>` : '';
|
||||||
|
|
||||||
|
$('#szczegoly').innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>${esc(e.title)}${e.label ? ` <span class="label">${esc(e.label)}</span>` : ''}</h2>
|
||||||
|
<dl>
|
||||||
|
${pole('Seria', e.series)}${pole('Wydawca', e.publisher)}
|
||||||
|
${pole('Dystrybutor', e.distributor)}${pole('Notatki', e.notes)}
|
||||||
|
${pole('Override silnika', e.engine_override)}
|
||||||
|
${pole('Override wersji', e.engine_version_override)}
|
||||||
|
${pole('Override kompilera', e.compiler_override)}
|
||||||
|
${pole('Override daty', e.release_date_override)}
|
||||||
|
<dt>Języki</dt><dd>${jezyki}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Kopie na dysku</h3>
|
||||||
|
${e.copies.map(c => `
|
||||||
|
<dl style="margin-bottom:12px">
|
||||||
|
<dt>Plik</dt><dd>${esc(c.display_name)}
|
||||||
|
${c.root_path ? `<span class="dim">(korzeń ${esc(c.root_path)}/)</span>` : ''}</dd>
|
||||||
|
<dt>Ścieżka</dt><dd class="mono">${esc(c.path)}</dd>
|
||||||
|
<dt>Nośnik</dt><dd>${esc(c.container)} / ${esc(c.fs_type || '—')},
|
||||||
|
${c.file_count} plików, ${mb(c.size)}, status ${esc(c.status)}</dd>
|
||||||
|
<dt>Źródło</dt><dd>${esc(c.source_kind)}${c.rip_tool ? ` · ${esc(c.rip_tool)}` : ''}
|
||||||
|
${c.verified ? '<span class="pill">zweryfikowane</span>' : ''}
|
||||||
|
${c.source_url ? `<br><a href="${esc(c.source_url)}">${esc(c.source_url)}</a>` : ''}</dd>
|
||||||
|
<dt>SHA-256</dt><dd class="mono">${esc(c.sha256 || '—')}</dd>
|
||||||
|
</dl>`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Wykryte fakty</h3>
|
||||||
|
<table><thead><tr><th>Pole</th><th>Wartość</th><th>Pewność</th></tr></thead><tbody>
|
||||||
|
${e.facts.map(f => `<tr style="cursor:default">
|
||||||
|
<td>${esc(f.field)}</td>
|
||||||
|
<td>${esc(f.value)}<div class="snip">${esc(f.evidence)}</div></td>
|
||||||
|
<td class="mono">${f.confidence.toFixed(2)}</td></tr>`).join('')}
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Pliki (${pliki.length}${pliki.length >= 400 ? ', obcięte' : ''})</h3>
|
||||||
|
<div class="row">
|
||||||
|
<input type="search" id="filtrpliki" placeholder="Filtruj po ścieżce…">
|
||||||
|
<select id="filtrformat" class="narrow"></select>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr><th>Ścieżka</th><th>Format</th><th>Rozmiar</th></tr></thead>
|
||||||
|
<tbody id="tabpliki"></tbody></table>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const formaty = [...new Set(pliki.map(p => p.format).filter(Boolean))].sort();
|
||||||
|
$('#filtrformat').innerHTML = '<option value="">wszystkie</option>' +
|
||||||
|
formaty.map(f => `<option>${esc(f)}</option>`).join('');
|
||||||
|
|
||||||
|
const rysujPliki = () => {
|
||||||
|
const q = $('#filtrpliki').value.toLowerCase(), fmt = $('#filtrformat').value;
|
||||||
|
const w = pliki.filter(p => (!q || p.path.toLowerCase().includes(q))
|
||||||
|
&& (!fmt || p.format === fmt));
|
||||||
|
$('#tabpliki').innerHTML = w.length ? w.map(p => `
|
||||||
|
<tr ${p.decoded ? `data-sha1="${p.sha1}"` : 'style="cursor:default"'}>
|
||||||
|
<td class="mono">${esc(p.path)}
|
||||||
|
${p.encrypted ? '<span class="pill">szyfr</span>' : ''}
|
||||||
|
${p.decoded ? '<span class="pill">podgląd</span>' : ''}</td>
|
||||||
|
<td>${esc(p.format || '—')}</td>
|
||||||
|
<td class="mono">${mb(p.size)}</td></tr>`).join('')
|
||||||
|
: '<tr><td colspan="3" class="empty">Nic nie pasuje.</td></tr>';
|
||||||
|
|
||||||
|
$('#tabpliki').querySelectorAll('tr[data-sha1]').forEach(tr =>
|
||||||
|
tr.onclick = () => otworzSkrypt(tr.dataset.sha1));
|
||||||
|
};
|
||||||
|
$('#filtrpliki').oninput = rysujPliki;
|
||||||
|
$('#filtrformat').onchange = rysujPliki;
|
||||||
|
rysujPliki();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- skrypty ----
|
||||||
|
let czekaj;
|
||||||
|
$('#szukaj').oninput = e => {
|
||||||
|
clearTimeout(czekaj);
|
||||||
|
const q = e.target.value.trim();
|
||||||
|
czekaj = setTimeout(() => szukajSkryptow(q), 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function szukajSkryptow(q) {
|
||||||
|
if (!q) { $('#wyniki').innerHTML = '<div class="empty">Wpisz zapytanie.</div>'; return; }
|
||||||
|
$('#wyniki').innerHTML = '<div class="empty">Szukam…</div>';
|
||||||
|
const r = await get('/api/search?limit=40&q=' + encodeURIComponent(q));
|
||||||
|
|
||||||
|
if (!r.hits.length) {
|
||||||
|
$('#wyniki').innerHTML = `<div class="empty">Brak trafień dla ${esc(r.query)}.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// snippet przychodzi z FTS5 z własnymi znacznikami » «, zamieniamy je po ucieczce
|
||||||
|
const podswietl = s => esc(s).replace(/»/g, '<mark>').replace(/«/g, '</mark>');
|
||||||
|
|
||||||
|
$('#wyniki').innerHTML =
|
||||||
|
`<div class="dim" style="margin-bottom:10px">${r.hits.length} trafień
|
||||||
|
dla <span class="mono">${esc(r.query)}</span>
|
||||||
|
${r.quoted ? '— potraktowane jako fraza' : ''}</div>` +
|
||||||
|
r.hits.map(h => `
|
||||||
|
<div class="wynik" data-sha1="${h.sha1}"
|
||||||
|
style="padding:8px;border-radius:6px;cursor:pointer">
|
||||||
|
<div class="mono">${esc(h.paths[0])}</div>
|
||||||
|
<div class="dim" style="font-size:12px">${esc(h.games.join(', '))}</div>
|
||||||
|
<div class="snip">${podswietl(h.snippet)}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
$('#wyniki').querySelectorAll('.wynik').forEach(d => {
|
||||||
|
d.onmouseenter = () => d.style.background = 'var(--hl)';
|
||||||
|
d.onmouseleave = () => d.style.background = '';
|
||||||
|
d.onclick = () => otworzSkrypt(d.dataset.sha1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function otworzSkrypt(sha1) {
|
||||||
|
document.querySelector('nav button[data-view=skrypty]').click();
|
||||||
|
$('#podglad').innerHTML = '<div class="card empty">Wczytuję…</div>';
|
||||||
|
const s = await get('/api/scripts/' + sha1);
|
||||||
|
$('#podglad').innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="mono" style="font-size:13px">${esc(s.paths[0])}</h2>
|
||||||
|
<div class="dim" style="font-size:12px;margin-bottom:4px">
|
||||||
|
${esc(s.games.join(', '))}</div>
|
||||||
|
<div class="mono dim" style="font-size:11px">sha1: ${esc(s.sha1)}</div>
|
||||||
|
${s.paths.length > 1 ? `<div class="dim" style="font-size:12px;margin-top:6px">
|
||||||
|
Ten sam plik pod ${s.paths.length} ścieżkami:<br>
|
||||||
|
<span class="mono">${s.paths.map(esc).join('<br>')}</span></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="card"><pre>${esc(s.body)}</pre></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- start ----
|
||||||
|
(async () => {
|
||||||
|
const s = await get('/api/stats');
|
||||||
|
$('#stats').textContent =
|
||||||
|
`${s.editions} wydań · ${s.copies} kopii · ${s.files.toLocaleString('pl')} plików `
|
||||||
|
+ `· ${s.blobs.toLocaleString('pl')} unikalnych · ${s.scripts} skryptów w indeksie `
|
||||||
|
+ `· ${mb(s.bytes)}`;
|
||||||
|
|
||||||
|
const max = Math.max(...s.formats.map(f => f.n));
|
||||||
|
$('#tabformaty').innerHTML = s.formats.map(f => `
|
||||||
|
<tr style="cursor:default"><td>${esc(f.format || '—')}</td>
|
||||||
|
<td class="mono">${f.n.toLocaleString('pl')}</td>
|
||||||
|
<td class="mono">${mb(f.bytes)}</td>
|
||||||
|
<td style="width:170px"><div class="bar"><i style="width:${f.n/max*100}%"></i></div></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
tytuly = await get('/api/titles');
|
||||||
|
rysujTytuly();
|
||||||
|
$('#filtr').oninput = e => rysujTytuly(e.target.value);
|
||||||
|
})().catch(e => $('#stats').textContent = 'Błąd: ' + e.message);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user