From 02988297ec165ffad3df1658153b3400288099a1 Mon Sep 17 00:00:00 2001 From: Patryk Gensch <43010113+patryk025@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:34:51 +0200 Subject: [PATCH] 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 --- .dockerignore | 12 + Dockerfile | 38 ++ compose.yaml | 17 + docker-entrypoint.sh | 14 + src/main/java/pl/genschu/rexcatalog/Main.java | 29 ++ .../pl/genschu/rexcatalog/mcp/McpServer.java | 17 +- .../pl/genschu/rexcatalog/web/WebServer.java | 420 ++++++++++++++++++ src/main/resources/web/index.html | 344 ++++++++++++++ 8 files changed, 889 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100755 docker-entrypoint.sh create mode 100644 src/main/java/pl/genschu/rexcatalog/web/WebServer.java create mode 100644 src/main/resources/web/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..276487b --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff79613 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4e43d23 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..0fec725 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/src/main/java/pl/genschu/rexcatalog/Main.java b/src/main/java/pl/genschu/rexcatalog/Main.java index 1609ada..165e9d4 100644 --- a/src/main/java/pl/genschu/rexcatalog/Main.java +++ b/src/main/java/pl/genschu/rexcatalog/Main.java @@ -6,10 +6,14 @@ import pl.genschu.rexcatalog.db.Database; import pl.genschu.rexcatalog.ingest.Ingestor; import pl.genschu.rexcatalog.ingest.MetadataDetector; import pl.genschu.rexcatalog.mcp.McpServer; +import pl.genschu.rexcatalog.web.WebServer; import pl.genschu.rexcatalog.script.ScriptDecoder; import pl.genschu.rexcatalog.script.ScriptSearch; +import com.sun.net.httpserver.HttpServer; + import java.io.File; +import java.net.InetSocketAddress; import java.nio.file.Path; import java.sql.ResultSet; import java.sql.SQLException; @@ -17,6 +21,7 @@ import java.sql.Statement; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.Executors; /** * CLI katalogu. Krok 1: indeksowanie obrazów do SQLite. @@ -46,6 +51,7 @@ public final class Main { case "set" -> set(dataDir, args); case "lang" -> lang(dataDir, args); case "mcp" -> mcp(dataDir, args); + case "serve" -> serve(dataDir, args); case "find" -> find(dataDir, args); case "cat" -> cat(dataDir, args); 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) { for (int i = 1; i < args.length - 1; i++) { if (args[i].equals(name)) { @@ -483,6 +511,7 @@ public final class Main { cat <ścieżka|sha1> wypisz odszyfrowany skrypt 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= (domyślnie ./data) """); diff --git a/src/main/java/pl/genschu/rexcatalog/mcp/McpServer.java b/src/main/java/pl/genschu/rexcatalog/mcp/McpServer.java index c5f77da..f398b34 100644 --- a/src/main/java/pl/genschu/rexcatalog/mcp/McpServer.java +++ b/src/main/java/pl/genschu/rexcatalog/mcp/McpServer.java @@ -52,12 +52,25 @@ public final class McpServer { public void start(String host, int port) throws IOException { http = HttpServer.create(new InetSocketAddress(host, port), 0); - http.createContext("/mcp", this::handle); - // pula wątków, ale dostęp do bazy i tak serializujemy — patrz dispatch() + register(http); + // pula wątków, ale dostęp do bazy i tak serializujemy — patrz toolsCall() http.setExecutor(Executors.newFixedThreadPool(4)); 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() { if (http != null) { http.stop(0); diff --git a/src/main/java/pl/genschu/rexcatalog/web/WebServer.java b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java new file mode 100644 index 0000000..dc2eeca --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/web/WebServer.java @@ -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. + * + *

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 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 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 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 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 parseQuery(String raw) { + Map 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); + } + } +} diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html new file mode 100644 index 0000000..672b32a --- /dev/null +++ b/src/main/resources/web/index.html @@ -0,0 +1,344 @@ + + + + + +rex-catalog + + + + +
+

rex·catalog

+
wczytuję…
+ +
+ +
+
+
+
+ +
+
+ + +
TytułSilnikDataKopie
+
+
Wybierz tytuł z listy.
+
+ + + + +
+ + + +