diff --git a/build.gradle b/build.gradle index 98b43e3..8894e7b 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,10 @@ dependencies { runtimeOnly "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" implementation 'org.xerial:sqlite-jdbc:3.46.1.3' + + // JSON-RPC serwera MCP. Transport stoi na com.sun.net.httpserver z JDK, + // więc poza parserem nie dokładamy nic. + implementation 'com.google.code.gson:gson:2.11.0' } application { diff --git a/src/main/java/pl/genschu/rexcatalog/Main.java b/src/main/java/pl/genschu/rexcatalog/Main.java index 1729606..1609ada 100644 --- a/src/main/java/pl/genschu/rexcatalog/Main.java +++ b/src/main/java/pl/genschu/rexcatalog/Main.java @@ -5,6 +5,7 @@ import pl.genschu.rexcatalog.catalog.Promoter; 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.script.ScriptDecoder; import pl.genschu.rexcatalog.script.ScriptSearch; @@ -44,6 +45,7 @@ public final class Main { case "titles" -> titles(dataDir); case "set" -> set(dataDir, args); case "lang" -> lang(dataDir, args); + case "mcp" -> mcp(dataDir, args); case "find" -> find(dataDir, args); case "cat" -> cat(dataDir, args); default -> usage(); @@ -294,6 +296,33 @@ public final class Main { } } + private static void mcp(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 server = new McpServer(db, dataDir); + server.start(host, port); + + System.out.printf("Serwer MCP nasłuchuje na http://%s:%d/mcp%n", host, port); + System.out.println("Transport: Streamable HTTP, bez sesji, tylko do odczytu."); + System.out.println("Zatrzymanie: Ctrl+C"); + + Runtime.getRuntime().addShutdownHook(new Thread(server::stop)); + // HttpServer pracuje na własnych wątkach, więc główny musi zaczekać + 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)) { + return args[i + 1]; + } + } + return fallback; + } + private static void find(Path dataDir, String[] args) throws Exception { if (args.length < 2) { System.err.println("find wymaga zapytania, np. find SHOWCURSOR"); @@ -453,6 +482,8 @@ public final class Main { find przeszukaj skrypty (składnia FTS5) cat <ścieżka|sha1> wypisz odszyfrowany skrypt + mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765) + 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 new file mode 100644 index 0000000..c5f77da --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/mcp/McpServer.java @@ -0,0 +1,306 @@ +package pl.genschu.rexcatalog.mcp; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import pl.genschu.rexcatalog.db.Database; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; + +/** + * Serwer MCP w transporcie Streamable HTTP, na {@code com.sun.net.httpserver} z JDK. + * + *

Sesji nie prowadzimy: wszystkie narzędzia są bezstanowe i tylko do odczytu, + * a specyfikacja pozwala pominąć {@code Mcp-Session-Id}. Z tego samego powodu + * {@code GET} na endpoint zwraca 405 — nie mamy komunikatów inicjowanych przez serwer, + * więc nie ma po co otwierać strumienia SSE. + */ +public final class McpServer { + + /** Wersje protokołu, które umiemy obsłużyć; pierwsza jest domyślna. */ + private static final List SUPPORTED_PROTOCOLS = + List.of("2025-06-18", "2025-03-26", "2024-11-05"); + + private static final String SERVER_NAME = "rex-catalog"; + private static final String SERVER_VERSION = "0.1.0"; + + private final Tools tools; + private final Map byName = new LinkedHashMap<>(); + private final Object lock = new Object(); + private HttpServer http; + + public McpServer(Database db, Path dataDir) { + this.tools = new Tools(db, dataDir); + for (Tools.Tool tool : tools.tools()) { + byName.put(tool.name(), tool); + } + } + + 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() + http.setExecutor(Executors.newFixedThreadPool(4)); + http.start(); + } + + public void stop() { + if (http != null) { + http.stop(0); + } + } + + private void handle(HttpExchange exchange) throws IOException { + try { + if (!isOriginAllowed(exchange)) { + // ochrona przed DNS rebinding — wymagana przez specyfikację transportu + respond(exchange, 403, "text/plain", "Niedozwolony nagłówek Origin"); + return; + } + switch (exchange.getRequestMethod()) { + case "POST" -> handlePost(exchange); + case "GET" -> respond(exchange, 405, "text/plain", + "Ten serwer nie wysyła komunikatów z własnej inicjatywy"); + case "DELETE" -> respond(exchange, 405, "text/plain", "Brak sesji do zamknięcia"); + default -> respond(exchange, 405, "text/plain", "Nieobsługiwana metoda"); + } + } catch (Exception e) { + respond(exchange, 500, "text/plain", "Błąd serwera: " + e); + } finally { + exchange.close(); + } + } + + private void handlePost(HttpExchange exchange) throws IOException { + String body; + try (InputStream in = exchange.getRequestBody()) { + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + + JsonElement parsed; + try { + parsed = JsonParser.parseString(body); + } catch (Exception e) { + respond(exchange, 400, "application/json", + error(null, -32700, "Nieparsowalny JSON").toString()); + return; + } + + // wsadu nie rozdzielamy na osobne odpowiedzi w strumieniu — zwracamy tablicę + if (parsed.isJsonArray()) { + JsonArray responses = new JsonArray(); + for (JsonElement element : parsed.getAsJsonArray()) { + JsonObject response = dispatch(element.getAsJsonObject()); + if (response != null) { + responses.add(response); + } + } + if (responses.isEmpty()) { + respond(exchange, 202, "text/plain", ""); + } else { + respond(exchange, 200, "application/json", responses.toString()); + } + return; + } + + JsonObject response = dispatch(parsed.getAsJsonObject()); + if (response == null) { + // powiadomienie: brak treści odpowiedzi + respond(exchange, 202, "text/plain", ""); + } else { + respond(exchange, 200, "application/json", response.toString()); + } + } + + /** Zwraca odpowiedź albo {@code null}, gdy przyszło powiadomienie. */ + private JsonObject dispatch(JsonObject request) { + JsonElement id = request.get("id"); + String method = request.has("method") ? request.get("method").getAsString() : ""; + boolean notification = id == null || id.isJsonNull(); + + try { + JsonObject result = switch (method) { + case "initialize" -> initialize(request); + case "tools/list" -> toolsList(); + case "tools/call" -> toolsCall(request); + case "ping" -> new JsonObject(); + default -> null; + }; + + if (notification) { + return null; + } + if (result == null) { + return error(id, -32601, "Nieznana metoda: " + method); + } + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + response.add("id", id); + response.add("result", result); + return response; + } catch (Exception e) { + if (notification) { + return null; + } + String message = e.getMessage() == null ? e.toString() : e.getMessage(); + return error(id, -32603, message); + } + } + + private JsonObject initialize(JsonObject request) { + String requested = null; + if (request.has("params") && request.getAsJsonObject("params").has("protocolVersion")) { + requested = request.getAsJsonObject("params").get("protocolVersion").getAsString(); + } + // odbijamy wersję klienta, jeśli ją znamy — inaczej proponujemy własną najnowszą + String agreed = SUPPORTED_PROTOCOLS.contains(requested) + ? requested : SUPPORTED_PROTOCOLS.get(0); + + JsonObject result = new JsonObject(); + result.addProperty("protocolVersion", agreed); + + JsonObject capabilities = new JsonObject(); + capabilities.add("tools", new JsonObject()); + result.add("capabilities", capabilities); + + JsonObject info = new JsonObject(); + info.addProperty("name", SERVER_NAME); + info.addProperty("version", SERVER_VERSION); + result.add("serverInfo", info); + + result.addProperty("instructions", + "Katalog kolekcji gier Aidem Media (seria Reksio, Poznaj Mity): obrazy płyt, " + + "sumy kontrolne, metadane wydań i odszyfrowane skrypty. " + + "Zacznij od list_titles, żeby poznać zawartość; " + + "search_scripts przeszukuje treść skryptów gier."); + return result; + } + + private JsonObject toolsList() { + JsonArray array = new JsonArray(); + for (Tools.Tool tool : byName.values()) { + JsonObject entry = new JsonObject(); + entry.addProperty("name", tool.name()); + entry.addProperty("description", tool.description()); + entry.add("inputSchema", tool.inputSchema()); + array.add(entry); + } + JsonObject result = new JsonObject(); + result.add("tools", array); + return result; + } + + private JsonObject toolsCall(JsonObject request) { + JsonObject params = request.has("params") + ? request.getAsJsonObject("params") : new JsonObject(); + String name = params.has("name") ? params.get("name").getAsString() : ""; + JsonObject arguments = params.has("arguments") && params.get("arguments").isJsonObject() + ? params.getAsJsonObject("arguments") : new JsonObject(); + + Tools.Tool tool = byName.get(name); + if (tool == null) { + return toolResult("Nie ma narzędzia o nazwie " + name + + ". Dostępne: " + String.join(", ", byName.keySet()), true); + } + + // wymagane argumenty sprawdzamy ze schematu, żeby brak parametru był błędem + // narzędzia, a nie cichym wynikiem policzonym z wartości domyślnej + List missing = new ArrayList<>(); + for (JsonElement required : tool.inputSchema().getAsJsonArray("required")) { + String key = required.getAsString(); + if (!arguments.has(key) || arguments.get(key).isJsonNull()) { + missing.add(key); + } + } + if (!missing.isEmpty()) { + return toolResult("Brakuje wymaganych argumentów narzędzia " + name + ": " + + String.join(", ", missing), true); + } + + try { + String text; + // sqlite-jdbc dzieli jedno połączenie, więc wywołania serializujemy; + // obciążenie jest czysto odczytowe i pojedynczy klient go nie odczuje + synchronized (lock) { + text = tool.handler().apply(arguments); + } + return toolResult(text, false); + } catch (Exception e) { + String message = e.getMessage() == null ? e.toString() : e.getMessage(); + return toolResult("Narzędzie " + name + " zawiodło: " + message, true); + } + } + + private static JsonObject toolResult(String text, boolean isError) { + JsonObject content = new JsonObject(); + content.addProperty("type", "text"); + content.addProperty("text", text); + JsonArray array = new JsonArray(); + array.add(content); + + JsonObject result = new JsonObject(); + result.add("content", array); + result.addProperty("isError", isError); + return result; + } + + private static JsonObject error(JsonElement id, int code, String message) { + JsonObject error = new JsonObject(); + error.addProperty("code", code); + error.addProperty("message", message); + + JsonObject response = new JsonObject(); + response.addProperty("jsonrpc", "2.0"); + if (id == null) { + response.add("id", com.google.gson.JsonNull.INSTANCE); + } else { + response.add("id", id); + } + response.add("error", error); + return response; + } + + /** + * Przeglądarka na dowolnej stronie może wysłać POST na localhost, więc żądanie + * z nagłówkiem Origin spoza pętli zwrotnej odrzucamy. Klienci MCP (procesy, + * nie strony) Origin zwykle nie wysyłają i takie żądania przechodzą. + */ + private static boolean isOriginAllowed(HttpExchange exchange) { + String origin = exchange.getRequestHeaders().getFirst("Origin"); + if (origin == null || origin.isBlank()) { + return true; + } + try { + String host = URI.create(origin).getHost(); + return host != null && Set.of("localhost", "127.0.0.1", "::1", "[::1]").contains(host); + } catch (Exception e) { + return false; + } + } + + private static void respond(HttpExchange exchange, int status, String contentType, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + if (!body.isEmpty()) { + exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8"); + } + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) { + exchange.getResponseBody().write(bytes); + } + } +} diff --git a/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java new file mode 100644 index 0000000..79c14f6 --- /dev/null +++ b/src/main/java/pl/genschu/rexcatalog/mcp/Tools.java @@ -0,0 +1,453 @@ +package pl.genschu.rexcatalog.mcp; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import pl.genschu.rexcatalog.db.Database; +import pl.genschu.rexcatalog.script.ScriptSearch; + +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.Map; + +/** + * Narzędzia MCP nad bazą katalogu. Wszystkie są tylko do odczytu — serwer ma + * odpowiadać na pytania o kolekcję, a nie ją modyfikować; indeksowanie i edycja + * zostają w CLI, gdzie widać, co się dzieje. + * + *

Odpowiedzi są tekstem sformatowanym dla modelu, nie JSON-em: agent ma je + * czytać, a nie parsować, a tekst zużywa mniej kontekstu niż to samo w obiektach. + */ +public final class Tools { + + private final Database db; + private final ScriptSearch search; + + public Tools(Database db, Path dataDir) { + this.db = db; + this.search = new ScriptSearch(db, dataDir); + } + + public interface Handler { + String apply(JsonObject arguments) throws Exception; + } + + public record Tool(String name, String description, JsonObject inputSchema, Handler handler) { + } + + public List tools() { + List tools = new ArrayList<>(); + + tools.add(new Tool("list_titles", + "Lista tytułów w kolekcji wraz z wydaniami: silnik, data builda, " + + "wersje językowe i liczba posiadanych kopii. Zacznij od tego, " + + "żeby poznać zawartość kolekcji.", + schema(Map.of(), List.of()), + args -> listTitles())); + + tools.add(new Tool("search_scripts", + "Przeszukuje pełnotekstowo odszyfrowane skrypty gier (CNV, DEF, CLASS). " + + "Składnia FTS5; identyfikatory z podkreśleniem są jednym tokenem. " + + "Zwraca ścieżkę, gry, w których plik występuje, fragment i SHA-1.", + schema(Map.of( + "query", property("string", "Zapytanie, np. CANVAS_OBSERVER albo TYPE=EPISODE"), + "limit", property("integer", "Maksymalna liczba trafień (domyślnie 20)") + ), List.of("query")), + args -> searchScripts(string(args, "query"), integer(args, "limit", 20)))); + + tools.add(new Tool("get_script", + "Zwraca pełną odszyfrowaną treść skryptu. Wskaż go pełnym SHA-1 " + + "albo fragmentem ścieżki; przy niejednoznaczności dostaniesz listę kandydatów.", + schema(Map.of("ref", property("string", "SHA-1 bloba albo fragment ścieżki")), + List.of("ref")), + args -> getScript(string(args, "ref")))); + + tools.add(new Tool("get_edition", + "Pełne metadane wydania: tytuł, silnik, daty, języki, kopie na dysku " + + "oraz wszystkie wykryte fakty wraz z dowodem i pewnością.", + schema(Map.of("edition_id", property("integer", "Identyfikator wydania z list_titles")), + List.of("edition_id")), + args -> getEdition(integer(args, "edition_id", -1)))); + + tools.add(new Tool("list_files", + "Wypisuje pliki należące do wydania. Można zawęzić po formacie " + + "(CNV, IMG, ANN, WAV...) i po fragmencie ścieżki.", + schema(Map.of( + "edition_id", property("integer", "Identyfikator wydania"), + "format", property("string", "Filtr formatu, np. CNV"), + "path_contains", property("string", "Fragment ścieżki"), + "limit", property("integer", "Maksymalna liczba plików (domyślnie 100)") + ), List.of("edition_id")), + args -> listFiles(integer(args, "edition_id", -1), string(args, "format"), + string(args, "path_contains"), integer(args, "limit", 100)))); + + tools.add(new Tool("find_file", + "Szuka pliku po fragmencie ścieżki w całej kolekcji i pokazuje, w których " + + "grach występuje oraz czy jest bit w bit ten sam. Przydatne do " + + "porównywania wydań.", + schema(Map.of( + "path_contains", property("string", "Fragment ścieżki, np. arcade.cnv"), + "limit", property("integer", "Maksymalna liczba plików (domyślnie 50)") + ), List.of("path_contains")), + args -> findFile(string(args, "path_contains"), integer(args, "limit", 50)))); + + tools.add(new Tool("collection_stats", + "Statystyki kolekcji: liczba kopii, plików, unikalnych blobów, " + + "rozkład formatów i stopień współdzielenia zasobów między grami.", + schema(Map.of(), List.of()), + args -> stats())); + + return tools; + } + + // ---------- implementacje ---------- + + private String listTitles() throws Exception { + StringBuilder sb = new StringBuilder(); + 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 || '/' || role, ', ') FROM edition_language + WHERE edition_id = e.id) AS langs, + (SELECT COUNT(*) FROM game_root WHERE edition_id = e.id) AS roots + 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()) { + sb.append("#").append(rs.getInt("edition_id")).append(" ") + .append(rs.getString("title")); + if (rs.getString("label") != null) { + sb.append(" [").append(rs.getString("label")).append("]"); + } + sb.append("\n silnik: ").append(nvl(rs.getString("engine"))) + .append(" data: ").append(nvl(rs.getString("release_date"))) + .append(" kopie: ").append(rs.getInt("roots")) + .append(" języki: ").append(nvl(rs.getString("langs"))) + .append("\n"); + } + } + if (sb.isEmpty()) { + return "Katalog jest pusty — uruchom ingest, decode, analyze, promote."; + } + return sb + "\nDaty pochodzą z LASTMODIFYTIME w application.def i przybliżają " + + "datę builda, nie datę wydania."; + } + + private String searchScripts(String query, int limit) throws Exception { + ScriptSearch.Result result = search.find(query, Math.min(limit, 100)); + if (result.hits().isEmpty()) { + return "Brak trafień dla: " + result.effectiveQuery(); + } + StringBuilder sb = new StringBuilder("Trafienia dla ") + .append(result.effectiveQuery()) + .append(result.quoted() ? " (potraktowane jako fraza)" : "") + .append(": ").append(result.hits().size()).append("\n\n"); + + for (ScriptSearch.Hit hit : result.hits()) { + sb.append(String.join("\n", hit.paths())).append("\n") + .append(" gry: ").append(String.join(", ", hit.games())).append("\n") + .append(" ").append(hit.snippet().replaceAll("\\s+", " ").trim()).append("\n") + .append(" sha1: ").append(hit.sha1()).append("\n\n"); + } + return sb.toString(); + } + + private String getScript(String ref) throws Exception { + List candidates = search.resolve(ref); + if (candidates.isEmpty()) { + return "Nic nie pasuje do: " + ref; + } + if (candidates.size() > 1) { + StringBuilder sb = new StringBuilder("Niejednoznaczne — " + + candidates.size() + " różnych wersji tego pliku:\n"); + for (String sha1 : candidates) { + sb.append(" ").append(sha1).append(" ") + .append(String.join(", ", search.gamesFor(sha1))).append("\n"); + } + return sb + "\nPowtórz z wybranym SHA-1."; + } + String sha1 = candidates.get(0); + String body = search.body(sha1); + if (body == null) { + return "Ten plik nie jest odszyfrowany (nie jest skryptem albo brak go w cache'u)."; + } + return "# " + String.join(", ", search.pathsFor(sha1)) + + "\n# gry: " + String.join(", ", search.gamesFor(sha1)) + + "\n# sha1: " + sha1 + "\n\n" + body; + } + + private String getEdition(int editionId) throws Exception { + StringBuilder sb = new StringBuilder(); + 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.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, editionId); + try (ResultSet rs = st.executeQuery()) { + if (!rs.next()) { + return "Nie ma wydania o id " + editionId + "."; + } + sb.append("Tytuł: ").append(rs.getString("title")).append("\n"); + append(sb, "Seria", rs.getString("series")); + append(sb, "Wydawca", rs.getString("publisher")); + append(sb, "Etykieta wydania", rs.getString("label")); + append(sb, "Dystrybutor", rs.getString("distributor")); + append(sb, "Notatki", rs.getString("notes")); + append(sb, "Hash biblioteki silnika", rs.getString("dll_sha1")); + append(sb, "Override silnika", rs.getString("engine_override")); + append(sb, "Override wersji silnika", rs.getString("engine_version_override")); + append(sb, "Override kompilera", rs.getString("compiler_override")); + append(sb, "Override daty", rs.getString("release_date_override")); + } + } + + sb.append("\nWersje językowe:\n"); + boolean any = false; + try (PreparedStatement st = db.connection().prepareStatement( + "SELECT lang, role FROM edition_language WHERE edition_id = ? ORDER BY lang, role")) { + st.setInt(1, editionId); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + any = true; + sb.append(" ").append(rs.getString("lang")) + .append(" (").append(rs.getString("role")).append(")\n"); + } + } + } + if (!any) { + sb.append(" (nie uzupełniono)\n"); + } + + sb.append("\nKopie na dysku:\n"); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT c.display_name, c.path, c.container, c.fs_type, c.status, c.file_count, + c.size, c.sha256, c.source_kind, c.source_url, 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, editionId); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + sb.append(" ").append(rs.getString("display_name")); + String root = rs.getString("root_path"); + if (root != null && !root.isEmpty()) { + sb.append(" (korzeń ").append(root).append("/)"); + } + sb.append("\n ").append(rs.getString("path")) + .append("\n ").append(rs.getString("container")) + .append(" / ").append(nvl(rs.getString("fs_type"))) + .append(", ").append(rs.getInt("file_count")).append(" plików, ") + .append(rs.getLong("size") / 1024 / 1024).append(" MB") + .append(", status ").append(rs.getString("status")) + .append("\n źródło: ").append(nvl(rs.getString("source_kind"))); + if (rs.getString("source_url") != null) { + sb.append(" — ").append(rs.getString("source_url")); + } + sb.append("\n sha256: ").append(nvl(rs.getString("sha256"))).append("\n"); + } + } + } + + sb.append("\nWykryte fakty (z dowodem):\n"); + 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, editionId); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + sb.append(String.format(" %-16s %-30s pewność %.2f%n", + rs.getString("field"), truncate(rs.getString("value"), 30), + rs.getDouble("confidence"))); + sb.append(" dowód: ").append(truncate(rs.getString("evidence"), 100)) + .append("\n"); + } + } + } + return sb.toString(); + } + + private String listFiles(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 + 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(java.util.Locale.ROOT)); + } + if (pathContains != null && !pathContains.isBlank()) { + sql.append(" AND f.path LIKE ?\n"); + params.add("%" + pathContains.toLowerCase(java.util.Locale.ROOT) + "%"); + } + sql.append("ORDER BY path LIMIT ?"); + params.add(Math.min(limit, 1000)); + + StringBuilder sb = new StringBuilder(); + int count = 0; + 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()) { + count++; + sb.append(String.format("%-60s %-8s %8d B%s %s%n", + truncate(rs.getString("path"), 60), nvl(rs.getString("format")), + rs.getLong("size"), rs.getInt("encrypted") == 1 ? " [szyfr]" : "", + rs.getString("blob_sha1"))); + } + } + } + if (count == 0) { + return "Brak plików spełniających kryteria."; + } + return "Plików: " + count + (count >= Math.min(limit, 1000) + ? " (obcięte do limitu — zawęź filtr)" : "") + "\n\n" + sb; + } + + private String findFile(String pathContains, int limit) throws Exception { + Map> byPath = new LinkedHashMap<>(); + Map> hashes = new LinkedHashMap<>(); + try (PreparedStatement st = db.connection().prepareStatement(""" + SELECT f.path AS path, f.blob_sha1, c.display_name + FROM file f JOIN copy c ON c.id = f.copy_id + WHERE f.path LIKE ? ORDER BY f.path, c.display_name LIMIT ? + """)) { + st.setString(1, "%" + pathContains.toLowerCase(java.util.Locale.ROOT) + "%"); + st.setInt(2, Math.min(limit, 500) * 8); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + String key = rs.getString("path"); + byPath.computeIfAbsent(key, k -> new ArrayList<>()) + .add(rs.getString("display_name") + " [" + + rs.getString("blob_sha1").substring(0, 8) + "]"); + hashes.computeIfAbsent(key, k -> new ArrayList<>()) + .add(rs.getString("blob_sha1")); + } + } + } + if (byPath.isEmpty()) { + return "Nic nie pasuje do: " + pathContains; + } + StringBuilder sb = new StringBuilder(); + int shown = 0; + for (Map.Entry> entry : byPath.entrySet()) { + if (shown++ >= limit) { + sb.append("\n(obcięte — zawęź fragment ścieżki)\n"); + break; + } + long distinct = hashes.get(entry.getKey()).stream().distinct().count(); + sb.append(entry.getKey()).append("\n ") + .append(String.join("\n ", entry.getValue())).append("\n") + .append(distinct == 1 + ? " → identyczna treść we wszystkich\n" + : " → " + distinct + " różne wersje treści\n") + .append("\n"); + } + return sb.toString(); + } + + private String stats() throws Exception { + StringBuilder sb = new StringBuilder(); + sb.append("kopie: ").append(scalar("SELECT COUNT(*) FROM copy")).append("\n") + .append("tytuły: ").append(scalar("SELECT COUNT(*) FROM title")).append("\n") + .append("wydania: ").append(scalar("SELECT COUNT(*) FROM edition")).append("\n") + .append("pliki (wystąpienia): ").append(scalar("SELECT COUNT(*) FROM file")).append("\n") + .append("bloby (unikalne): ").append(scalar("SELECT COUNT(*) FROM blob")).append("\n") + .append("bloby w >1 kopii: ").append(scalar( + "SELECT COUNT(*) FROM (SELECT blob_sha1 FROM file GROUP BY blob_sha1 " + + "HAVING COUNT(DISTINCT copy_id) > 1)")).append("\n") + .append("skrypty w indeksie: ").append(scalar("SELECT COUNT(*) FROM script_fts")) + .append("\n\nFormaty:\n"); + + 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()) { + sb.append(String.format(" %-10s %6d %8.1f MB%n", nvl(rs.getString("format")), + rs.getInt("n"), rs.getLong("bytes") / 1024.0 / 1024.0)); + } + } + return sb.toString(); + } + + // ---------- pomocnicze ---------- + + 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 void append(StringBuilder sb, String label, String value) { + if (value != null && !value.isBlank()) { + sb.append(label).append(": ").append(value).append("\n"); + } + } + + private static JsonObject property(String type, String description) { + JsonObject property = new JsonObject(); + property.addProperty("type", type); + property.addProperty("description", description); + return property; + } + + private static JsonObject schema(Map properties, List required) { + JsonObject schema = new JsonObject(); + schema.addProperty("type", "object"); + JsonObject props = new JsonObject(); + properties.forEach(props::add); + schema.add("properties", props); + JsonArray requiredArray = new JsonArray(); + required.forEach(requiredArray::add); + schema.add("required", requiredArray); + return schema; + } + + private static String string(JsonObject args, String name) { + return args != null && args.has(name) && !args.get(name).isJsonNull() + ? args.get(name).getAsString() : null; + } + + private static int integer(JsonObject args, String name, int fallback) { + return args != null && args.has(name) && !args.get(name).isJsonNull() + ? args.get(name).getAsInt() : fallback; + } + + private static String nvl(String value) { + return value == null ? "—" : value; + } + + private static String truncate(String value, int max) { + if (value == null) { + return "—"; + } + return value.length() <= max ? value : value.substring(0, max - 1) + "…"; + } +}