diff --git a/modules/GephiAI/LICENSE b/modules/GephiAI/LICENSE new file mode 100644 index 000000000..f444669ea --- /dev/null +++ b/modules/GephiAI/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Matt Artz + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/modules/GephiAI/README.md b/modules/GephiAI/README.md new file mode 100644 index 000000000..d627a81f4 --- /dev/null +++ b/modules/GephiAI/README.md @@ -0,0 +1,51 @@ +# Gephi AI + +This plugin lets an AI assistant drive Gephi. It runs a small HTTP API inside +Gephi Desktop, so an assistant such as Claude can create projects, build and +edit graphs, run layouts and statistics, style and filter the result, and export +it, while you watch the work happen in the Gephi window. + +The plugin is the Gephi half of the system. The assistant talks to it through a +separate Model Context Protocol server, which is distributed independently and +is not required for this plugin to build or install. + +## Quick start + +- Requires Gephi 0.11.3 or later. +- Install the plugin in Gephi and restart. +- The API starts automatically and listens on `http://127.0.0.1:8080`. +- Open **Tools > Gephi AI Server** to see whether it is running and on which URL, + to stop or start it, and to change the port. The port setting persists and + takes effect the next time the server starts. +- Point an MCP client at the companion server. Setup instructions are at + https://github.com/MattArtzAnthro/gephi-ai + +Check that it is running from a terminal: + +``` +curl http://127.0.0.1:8080/health +``` + +Opening that URL in a browser returns `403` by design. See below. + +## Scope and safety + +The listener binds to the loopback interface only, so it is not reachable from +the network. + +Two checks keep it that way. A request whose `Host` header names anything but a +loopback address is refused, which blocks DNS rebinding. A request carrying +`Origin` or `Sec-Fetch-Site` is also refused, because both are set by browsers +and cannot be forged by page JavaScript. Without that second check a page the +user merely visits could call the API with `mode: "no-cors"`, and although the +browser would hide the reply, the side effect would already have happened. +Local clients such as the companion server send neither header. + +Beyond that there is no authentication, so any process running as you can drive +the API. The API is read and write: an assistant driving it can delete nodes, +clear a workspace, and overwrite files you name. Save your work before a long +session. + +## Licence + +Apache License 2.0. See `LICENSE`. diff --git a/modules/GephiAI/pom.xml b/modules/GephiAI/pom.xml new file mode 100644 index 000000000..fd22457c9 --- /dev/null +++ b/modules/GephiAI/pom.xml @@ -0,0 +1,222 @@ + + + 4.0.0 + + + org.gephi + gephi-plugin-parent + 0.11.3 + + + org.gephi.plugins + gephi-ai + 1.5.1 + nbm + + Gephi AI + HTTP API for remote Gephi control via MCP (Model Context Protocol) + https://github.com/MattArtzAnthro/gephi-ai + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Matt Artz + https://www.mattartz.me + + + + + + + org.gephi + graph-api + + + org.gephi + project-api + + + org.gephi + layout-api + + + org.gephi + io-exporter-api + + + org.gephi + io-importer-api + + + org.gephi + statistics-api + + + org.gephi + utils-longtask + + + org.gephi + preview-api + + + org.gephi + filters-api + + + org.gephi + visualization-api + + + org.gephi + perspective-api + + + org.gephi + datalab-api + + + org.gephi + timeline-api + + + + + org.netbeans.api + org-openide-util + + + org.netbeans.api + org-openide-util-lookup + + + org.netbeans.api + org-openide-modules + + + org.netbeans.api + org-openide-nodes + + + org.netbeans.api + org-openide-awt + + + org.netbeans.api + org-openide-dialogs + + + + org.gephi + appearance-api + + + org.gephi + appearance-plugin + + + org.gephi + core-library-wrapper + + + org.gephi + desktop-appearance + + + org.gephi + desktop-statistics + + + org.gephi + io-exporter-preview + + + + + com.google.code.gson + gson + 2.14.0 + + + + + + org.gephi + filters-impl + test + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + org.gephi + layout-plugin + test + + + org.gephi + io-importer-plugin + test + + + org.netbeans.modules + org-netbeans-modules-masterfs + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + 0 + + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + Apache 2.0 + LICENSE + Matt Artz + https://www.mattartz.me + https://github.com/MattArtzAnthro/gephi-ai + https://github.com/MattArtzAnthro/gephi-ai + + org.gephi.plugins.mcp.api + + + + + + diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/Installer.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/Installer.java new file mode 100644 index 000000000..a5118dc42 --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/Installer.java @@ -0,0 +1,253 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp; + +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.gephi.plugins.mcp.api.GephiAPIServer; +import org.gephi.plugins.mcp.service.GephiControlService; +import org.gephi.plugins.mcp.ui.BindFailureNotifier; +import org.gephi.visualization.api.VisualizationController; +import org.openide.modules.ModuleInstall; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; +import org.openide.util.NbPreferences; + +/** + * Module lifecycle for the Gephi AI plugin. Starts the local HTTP API when the + * module is restored and stops it when the module closes. The server can also + * be started and stopped from Tools, Gephi AI Server, which calls the static + * entry points below. This class stays free of Swing; all user interface work + * lives in the ui package. + */ +public class Installer extends ModuleInstall { + + private static final Logger LOGGER = Logger.getLogger(Installer.class.getName()); + + static final int DEFAULT_PORT = 8080; + static final String PREF_PORT = "port"; + + /** The live module instance, published by restored() for the UI entry points. */ + private static volatile Installer instance; + + /** + * Written by the MCP-Server-Starter thread and read by other threads, + * including the event dispatch thread in stopNow(). Volatile so a stop can + * never read a stale null and skip a live server. + */ + private volatile GephiAPIServer server; + + /** + * True once the module is closing or the user has stopped the server. + * Checked before the delayed startup constructs the server, so a disable + * that lands inside the startup delay cannot be followed by a start that + * nothing can ever stop. + */ + private volatile boolean stopped; + + @Override + public void restored() { + instance = this; + stopped = false; + Thread serverThread = new Thread(() -> { + try { + Thread.sleep(2000); // Let Gephi finish initializing before binding the port. + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + String error = startNow(); + if (error != null) { + BindFailureNotifier.notifyStartupFailure(error); + } + installClickListenerWhenVisualizationReady(); + }, "MCP-Server-Starter"); + serverThread.setDaemon(true); + serverThread.start(); + } + + @Override + public void close() { + stopped = true; + stopNow(); + } + + @Override + public boolean closing() { + stopped = true; + stopNow(); + return true; + } + + // ─── Entry points for the Tools menu dialog ────────────────────────── + + /** True when the API server is currently listening. */ + public static boolean isServerRunning() { + Installer i = instance; + return i != null && i.isRunningNow(); + } + + /** The port the running server is bound to, or -1 when it is not running. */ + public static int getRunningPort() { + Installer i = instance; + GephiAPIServer s = (i == null) ? null : i.server; + return (s != null && s.isAlive()) ? s.getListeningPort() : -1; + } + + /** + * The port the next start will bind: the persisted preference if set, + * otherwise the gephi.mcp.port system property, otherwise 8080. + */ + public static int getPreferredPort() { + int fallback = Integer.getInteger("gephi.mcp.port", DEFAULT_PORT); + return NbPreferences.forModule(Installer.class).getInt(PREF_PORT, fallback); + } + + /** Persists the port. Takes effect the next time the server starts. */ + public static void setPreferredPort(int port) { + NbPreferences.forModule(Installer.class).putInt(PREF_PORT, port); + } + + /** + * Starts the server on the preferred port if it is not already running. + * Returns null on success, or a user-facing error message on failure. + * Callers must invoke this off the event dispatch thread. + */ + public static String requestStart() { + Installer i = instance; + if (i == null) { + return NbBundle.getMessage(Installer.class, "Installer.error.moduleNotReady"); + } + i.stopped = false; + return i.startNow(); + } + + /** Stops the server if it is running, and cancels any pending delayed start. */ + public static void requestStop() { + Installer i = instance; + if (i == null) { + return; + } + i.stopped = true; + i.stopNow(); + } + + // ─── Lifecycle ─────────────────────────────────────────────────────── + + /** True when this instance's server is currently listening. */ + boolean isRunningNow() { + GephiAPIServer s = server; + return s != null && s.isAlive(); + } + + /** + * Starts the server unless it is already running or the module has been + * stopped. Returns null when the server is running afterwards, or when the + * start was cancelled by a stop; returns a user-facing message when the + * bind failed. Synchronized against stopNow() so a start and a stop can + * never interleave. + */ + synchronized String startNow() { + if (stopped) { + return null; // The module was disabled or stopped while the start was pending. + } + if (isRunningNow()) { + return null; + } + int port = getPreferredPort(); + GephiAPIServer s = new GephiAPIServer(port); + try { + s.startServer(); + } catch (IOException e) { + String detail = e.getLocalizedMessage() != null ? e.getLocalizedMessage() : e.toString(); + LOGGER.log(Level.SEVERE, "Gephi AI server failed to bind port " + port, e); + return NbBundle.getMessage(Installer.class, "Installer.error.bindFailed", + Integer.toString(port), detail); + } + server = s; + if (stopped) { + // A stop request arrived while the bind was in flight; honor it. + stopNow(); + return null; + } + LOGGER.log(Level.INFO, "Gephi AI API listening on http://127.0.0.1:{0}", + Integer.toString(port)); + return null; + } + + /** + * Installs the passive node click listener as soon as Gephi's visualization + * controller is available, so clicks made before the first API call are + * recorded rather than silently dropped. A single call after the bind would + * not be enough: GephiControlService.ensureClickListener no-ops while the + * controller is absent from the Lookup and never retries on its own, and at + * two seconds after startup the controller may not exist yet. Polling until + * the controller appears makes the install deterministic. Runs on the + * daemon starter thread, bounded, and honors a module stop. + */ + private void installClickListenerWhenVisualizationReady() { + for (int i = 0; i < 120 && !stopped; i++) { + if (Lookup.getDefault().lookup(VisualizationController.class) != null) { + GephiControlService.getInstance().ensureClickListener(); + LOGGER.fine("Gephi AI click listener installed at startup"); + return; + } + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + LOGGER.fine("Gephi AI click listener not installed; visualization controller unavailable"); + } + + /** + * Stops the server. Safe to call from any thread, including the event + * dispatch thread; never blocks for more than three seconds. + */ + private synchronized void stopNow() { + final GephiAPIServer s = server; + server = null; + if (s == null) { + return; + } + // Stop on a daemon watchdog so a slow or stuck socket close can never + // block Gephi's shutdown, which runs on the event dispatch thread. Wait + // at most three seconds, then continue regardless; the daemon threads + // cannot keep the JVM alive. + Thread stopper = new Thread(() -> { + try { + s.stopServer(); + LOGGER.info("Gephi AI API stopped"); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Error stopping the Gephi AI server", e); + } + }, "MCP-Server-Stopper"); + stopper.setDaemon(true); + stopper.start(); + try { + stopper.join(3000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (stopper.isAlive()) { + LOGGER.warning("Gephi AI server stop exceeded three seconds; continuing shutdown"); + } + } +} diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java new file mode 100644 index 000000000..9e9edbe91 --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java @@ -0,0 +1,1239 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.gephi.plugins.mcp.service.GephiControlService; + +public class GephiAPIServer implements HttpHandler { + + private static final Logger LOGGER = Logger.getLogger(GephiAPIServer.class.getName()); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private final GephiControlService service; + private final int port; + private volatile HttpServer httpServer; + private volatile ExecutorService executor; + + public GephiAPIServer(int port) { + this.port = port; + this.service = GephiControlService.getInstance(); + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + try { + String uri = exchange.getRequestURI().getPath(); + String method = exchange.getRequestMethod(); + + // FINE, not INFO: a client polling /health or /layout/status would otherwise + // fill messages.log for the whole session. Start and stop stay at INFO. + LOGGER.fine(() -> "Gephi AI API: " + method + " " + uri); + + // Defense against DNS-rebinding: the API is reachable only from local + // processes (the MCP server is a local Python process, not a browser). + // Reject any request whose Host header points at a non-local name, which + // is how a malicious web page would try to reach 127.0.0.1 via a rebound + // hostname. Requests with no Host header (e.g. raw curl) are allowed. + if (!isNonBrowserRequest(exchange.getRequestHeaders().getFirst("Origin"), + exchange.getRequestHeaders().getFirst("Sec-Fetch-Site"))) { + JsonObject error = new JsonObject(); + error.addProperty("success", false); + error.addProperty("error", "Forbidden: this API is not reachable from a browser"); + sendJson(exchange, 403, error); + return; + } + + if (!isLoopbackHost(exchange.getRequestHeaders().getFirst("Host"))) { + JsonObject error = new JsonObject(); + error.addProperty("success", false); + error.addProperty("error", "Forbidden: requests must target localhost"); + sendJson(exchange, 403, error); + return; + } + + if ("OPTIONS".equals(method)) { + sendText(exchange, 200, ""); + return; + } + + try { + JsonObject requestBody = null; + if ("POST".equals(method) || "PUT".equals(method)) { + WelcomeCloser.closeIfOpen(); + String body = readBody(exchange.getRequestBody(), + exchange.getRequestHeaders().getFirst("Content-Length")); + if (body != null && !body.isEmpty()) { + requestBody = JsonParser.parseString(body).getAsJsonObject(); + } + } + + Map params = parseQuery(exchange.getRequestURI().getRawQuery()); + JsonObject result = routeRequest(uri, method, params, requestBody); + + int status = result.has("success") && result.get("success").getAsBoolean() ? 200 : 400; + + sendJson(exchange, status, result); + + } catch (Exception e) { + LOGGER.log(Level.WARNING, "API error", e); + JsonObject error = new JsonObject(); + error.addProperty("success", false); + error.addProperty("error", e.getMessage()); + sendJson(exchange, 500, error); + } + } finally { + exchange.close(); + } + } + + private void sendJson(HttpExchange exchange, int status, JsonObject body) throws IOException { + sendText(exchange, status, GSON.toJson(body), "application/json"); + } + + private void sendText(HttpExchange exchange, int status, String body) throws IOException { + sendText(exchange, status, body, "text/plain"); + } + + private void sendText(HttpExchange exchange, int status, String body, String contentType) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", contentType); + if (bytes.length == 0) { + // 0 would switch sendResponseHeaders to chunked encoding; -1 means "no body". + exchange.sendResponseHeaders(status, -1); + } else { + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + } + + /** Query string parsing (application/x-www-form-urlencoded), same shape as NanoHTTPD's getParms(). */ + static Map parseQuery(String rawQuery) { + Map params = new HashMap<>(); + if (rawQuery == null || rawQuery.isEmpty()) { + return params; + } + for (String pair : rawQuery.split("&")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + String key = eq >= 0 ? pair.substring(0, eq) : pair; + String value = eq >= 0 ? pair.substring(eq + 1) : ""; + params.put(urlDecode(key), urlDecode(value)); + } + return params; + } + + /** Percent- and plus-decoded as UTF-8; text that is not valid percent-encoding is kept as sent. */ + private static String urlDecode(String s) { + try { + return URLDecoder.decode(s, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return s; + } + } + + /** + * True when the request did not come from a browser. + * + *

The Host check below stops DNS rebinding, but not a page the user is merely visiting + * calling {@code fetch("http://127.0.0.1:8080/...", {mode:"no-cors"})}. That request carries + * a loopback Host and a CORS-safelisted content type, so it passes every other check, and + * although CORS hides the reply the side effect has already happened. Destructive endpoints + * and the export endpoints, which write to a caller-supplied path, are reachable that way. + * + *

Browsers set {@code Origin} on such a request and {@code Sec-Fetch-Site} on every + * request, and page JavaScript can neither forge nor suppress them. Legitimate clients here + * are local processes, which send neither, so refusing on either header costs nothing and + * closes the hole. Kept package-private and static so it is unit-testable without a server. + */ + static boolean isNonBrowserRequest(String origin, String secFetchSite) { + boolean hasOrigin = origin != null && !origin.trim().isEmpty(); + boolean hasFetchSite = secFetchSite != null && !secFetchSite.trim().isEmpty(); + return !hasOrigin && !hasFetchSite; + } + + /** + * Reads a request body of {@code contentLength} bytes (the raw Content-Length header, + * possibly null, in which case the stream is read to its end) and decodes it as UTF-8. + * Request bodies are JSON, which is UTF-8, whatever charset the Content-Type names or + * omits. Package-private and static so it can be unit-tested without a live server. + */ + static String readBody(InputStream in, String contentLength) throws IOException { + if (contentLength == null || contentLength.isBlank()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + long length = Long.parseLong(contentLength.trim()); + if (length <= 0) { + return ""; + } + return new String(in.readNBytes((int) length), StandardCharsets.UTF_8); + } + + /** + * True when {@code host} (a raw HTTP Host header value, possibly null or with a + * port and/or IPv6 brackets) names the loopback interface. A null/empty header is + * allowed (non-browser clients like the MCP server may omit it); any non-loopback + * name is rejected, which is what blocks DNS-rebinding attacks from a web page. + * Package-private and static so it can be unit-tested without a live server. + */ + static boolean isLoopbackHost(String host) { + if (host == null || host.isEmpty()) { + return true; + } + String name = host; + int colon = name.lastIndexOf(':'); + if (colon > -1 && name.indexOf(']') < colon) { + name = name.substring(0, colon); + } + name = name.replace("[", "").replace("]", "").trim().toLowerCase(); + return name.equals("127.0.0.1") || name.equals("localhost") || name.equals("::1"); + } + + @SuppressWarnings("unchecked") + private JsonObject routeRequest(String uri, String method, Map params, JsonObject body) { + + // ─── Health ────────────────────────────────────────────────── + + if ("/health".equals(uri) || "/".equals(uri)) { + JsonObject result = new JsonObject(); + result.addProperty("success", true); + result.addProperty("service", "Gephi AI API"); + result.addProperty("version", moduleVersion()); + String gephi = gephiVersion(); + if (gephi != null) { + result.addProperty("gephi_version", gephi); + } + result.addProperty("status", "running"); + // "busy" here (persistently) means Gephi is wedged and needs a restart. + result.addProperty("graph_lock", service.graphLockProbe()); + result.add("graph_lock_stats", service.graphLockStats()); + // No side effects here: /health is a pure liveness probe that clients may + // poll at any frequency. The node click listener is installed once at + // plugin startup by Installer, as soon as the visualization controller is + // available, and again defensively by /selection (GephiControlService. + // getSelection calls ensureClickListener itself). + return result; + } + + // ─── Human selection journal ───────────────────────────────── + + if ("/selection".equals(uri) && "GET".equals(method)) { + // Default false, matching the Python tool: peeking must not consume. + boolean clear = "true".equalsIgnoreCase(params.get("clear")); + return service.getSelection(clear); + } + + // ─── View / camera (teaching mode) ─────────────────────────── + + if ("/view/focus".equals(uri) && "POST".equals(method)) { + String mode = body != null && body.has("mode") ? body.get("mode").getAsString() : "graph"; + String id = body != null && body.has("id") ? body.get("id").getAsString() : null; + String source = body != null && body.has("source") ? body.get("source").getAsString() : null; + String target = body != null && body.has("target") ? body.get("target").getAsString() : null; + Double x = body != null && body.has("x") ? body.get("x").getAsDouble() : null; + Double y = body != null && body.has("y") ? body.get("y").getAsDouble() : null; + Double w = body != null && body.has("w") ? body.get("w").getAsDouble() : null; + Double h = body != null && body.has("h") ? body.get("h").getAsDouble() : null; + Double zoom = body != null && body.has("zoom") ? body.get("zoom").getAsDouble() : null; + java.util.List select = null; + if (body != null && body.has("select")) { + select = new java.util.ArrayList<>(); + for (com.google.gson.JsonElement el : body.get("select").getAsJsonArray()) { + select.add(el.getAsString()); + } + } + return service.focusView(mode, id, source, target, x, y, w, h, zoom, select); + } + + if ("/view/selection".equals(uri) && "POST".equals(method)) { + String mode = body != null && body.has("mode") ? body.get("mode").getAsString() : "rectangle"; + return service.setSelectionMode(mode); + } + + if ("/perspective".equals(uri) && "GET".equals(method)) { + return service.getPerspective(); + } + + if ("/perspective/switch".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("name")) { + return errorResult("Missing 'name'"); + } + return service.switchPerspective(body.get("name").getAsString()); + } + + // ─── Project ───────────────────────────────────────────────── + + if ("/project/new".equals(uri) && "POST".equals(method)) { + String name = body != null && body.has("name") ? body.get("name").getAsString() : "New Project"; + return service.createProject(name); + } + + if ("/project/open".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file' parameter"); + } + return service.openProject(body.get("file").getAsString()); + } + + if ("/project/save".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file' parameter"); + } + return service.saveProject(body.get("file").getAsString()); + } + + if ("/project/info".equals(uri) && "GET".equals(method)) { + return service.getProjectInfo(); + } + + // ─── Workspace ─────────────────────────────────────────────── + + if ("/workspace/new".equals(uri) && "POST".equals(method)) { + return service.newWorkspace(); + } + + if ("/workspace/list".equals(uri) && "GET".equals(method)) { + return service.listWorkspaces(); + } + + if ("/workspace/switch".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("index")) { + return errorResult("Missing 'index'"); + } + return service.switchWorkspace(body.get("index").getAsInt()); + } + + if ("/workspace/delete".equals(uri) && "DELETE".equals(method)) { + // The workspace index arrives as a query parameter (?index=N). Request + // bodies are parsed for POST and PUT only, so a JSON body on this DELETE + // was never readable; the query parameter is the supported form. + int index = parseIntParam(params.get("index"), -1); + if (index < 0) { + return errorResult("Missing 'index' query parameter"); + } + return service.deleteWorkspace(index); + } + + if ("/workspace/duplicate".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("index")) { + return errorResult("Missing 'index'"); + } + return service.duplicateWorkspace(body.get("index").getAsInt()); + } + + if ("/workspace/rename".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("index") || !body.has("name")) { + return errorResult("Missing 'index' or 'name'"); + } + return service.renameWorkspace(body.get("index").getAsInt(), body.get("name").getAsString()); + } + + // ─── Nodes ─────────────────────────────────────────────────── + + if ("/graph/node/add".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("id")) { + return errorResult("Missing 'id' parameter"); + } + String id = body.get("id").getAsString(); + String label = body.has("label") ? body.get("label").getAsString() : null; + Map attrs = null; + if (body.has("attributes") && body.get("attributes").isJsonObject()) { + attrs = GSON.fromJson(body.get("attributes"), Map.class); + } + return service.addNode(id, label, attrs); + } + + if ("/graph/nodes/add".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("nodes")) { + return errorResult("Missing 'nodes' array"); + } + List> nodes = GSON.fromJson(body.get("nodes"), List.class); + return service.addNodes(nodes); + } + + if (uri.startsWith("/graph/node/") && uri.length() > "/graph/node/".length() && "DELETE".equals(method)) { + String nodeId = uri.substring("/graph/node/".length()); + if (nodeId.isEmpty()) { + return errorResult("Missing node ID"); + } + return service.removeNode(nodeId); + } + + if ("/graph/nodes/remove".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("ids")) { + return errorResult("Missing 'ids' array"); + } + List ids = GSON.fromJson(body.get("ids"), List.class); + return service.bulkRemoveNodes(ids); + } + + if ("/graph/nodes".equals(uri) && "GET".equals(method)) { + int limit = parseIntParam(params.get("limit"), 100); + int offset = parseIntParam(params.get("offset"), 0); + return service.queryNodes(params.get("column"), params.get("value"), params.get("contains"), + doubleParamOrNull(params.get("min")), doubleParamOrNull(params.get("max")), + limit, offset, visibleParam(params, false), params.get("sort_by"), + !"false".equalsIgnoreCase(params.get("descending")), params.get("columns")); + } + + if (uri.startsWith("/graph/node/get/") && "GET".equals(method)) { + String nodeId = uri.substring("/graph/node/get/".length()); + if (nodeId.isEmpty()) { + return errorResult("Missing node ID"); + } + return service.getNode(nodeId); + } + + if ("/graph/node/label".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("id") || !body.has("label")) { + return errorResult("Missing 'id' or 'label'"); + } + return service.setNodeLabel(body.get("id").getAsString(), body.get("label").getAsString()); + } + + if ("/graph/node/position".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("id")) { + return errorResult("Missing 'id'"); + } + float x = body.has("x") ? body.get("x").getAsFloat() : 0; + float y = body.has("y") ? body.get("y").getAsFloat() : 0; + return service.setNodePosition(body.get("id").getAsString(), x, y); + } + + if ("/graph/nodes/positions".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("positions")) { + return errorResult("Missing 'positions' array"); + } + List> positions = GSON.fromJson(body.get("positions"), List.class); + return service.batchSetPositions(positions); + } + + // ─── Edges ─────────────────────────────────────────────────── + + if ("/graph/edge/add".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) { + return errorResult("Missing 'source' or 'target'"); + } + String source = body.get("source").getAsString(); + String target = body.get("target").getAsString(); + Double weight = body.has("weight") ? body.get("weight").getAsDouble() : 1.0; + boolean directed = !body.has("directed") || body.get("directed").getAsBoolean(); + String edgeType = body.has("edge_type") ? body.get("edge_type").getAsString() : null; + return service.addEdge(source, target, weight, directed, edgeType); + } + + if ("/graph/edges/add".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("edges")) { + return errorResult("Missing 'edges' array"); + } + List> edges = GSON.fromJson(body.get("edges"), List.class); + return service.addEdges(edges); + } + + if ("/graph/edge/remove".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) { + return errorResult("Missing 'source' or 'target'"); + } + return service.removeEdge(body.get("source").getAsString(), body.get("target").getAsString()); + } + + if ("/graph/edge/weight".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target") || !body.has("weight")) { + return errorResult("Missing 'source', 'target', or 'weight'"); + } + return service.setEdgeWeight( + body.get("source").getAsString(), + body.get("target").getAsString(), + body.get("weight").getAsDouble() + ); + } + + if ("/graph/edge/label".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target") || !body.has("label")) { + return errorResult("Missing 'source', 'target', or 'label'"); + } + return service.setEdgeLabel( + body.get("source").getAsString(), + body.get("target").getAsString(), + body.get("label").getAsString() + ); + } + + if ("/graph/edges".equals(uri) && "GET".equals(method)) { + int limit = parseIntParam(params.get("limit"), 100); + int offset = parseIntParam(params.get("offset"), 0); + return service.queryEdges(limit, offset, visibleParam(params, false)); + } + + // ─── Graph Stats & Type ────────────────────────────────────── + + if ("/graph/stats".equals(uri) && "GET".equals(method)) { + return service.getGraphStats(visibleParam(params, false)); + } + + if ("/graph/type".equals(uri) && "GET".equals(method)) { + return service.getGraphType(); + } + + // ─── Attributes / Columns ──────────────────────────────────── + + if ("/graph/columns".equals(uri) && "GET".equals(method)) { + String target = params.getOrDefault("target", "node"); + return service.getColumns(target); + } + + if ("/graph/columns/add".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("name") || !body.has("type")) { + return errorResult("Missing 'name' or 'type'"); + } + String target = body.has("target") ? body.get("target").getAsString() : "node"; + return service.addColumn(body.get("name").getAsString(), body.get("type").getAsString(), target); + } + + if ("/graph/node/attributes".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("id") || !body.has("attributes")) { + return errorResult("Missing 'id' or 'attributes'"); + } + Map attrs = GSON.fromJson(body.get("attributes"), Map.class); + return service.setNodeAttributes(body.get("id").getAsString(), attrs); + } + + if ("/graph/nodes/attributes".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("updates")) { + return errorResult("Missing 'updates' array"); + } + List> updates = GSON.fromJson(body.get("updates"), List.class); + return service.batchSetNodeAttributes(updates); + } + + if ("/graph/edge/attributes".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target") || !body.has("attributes")) { + return errorResult("Missing 'source', 'target', or 'attributes'"); + } + Map attrs = GSON.fromJson(body.get("attributes"), Map.class); + return service.setEdgeAttributes( + body.get("source").getAsString(), + body.get("target").getAsString(), + attrs + ); + } + + // ─── Appearance ────────────────────────────────────────────── + + if ("/appearance/node/color".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("id")) { + return errorResult("Missing 'id'"); + } + int r = body.has("r") ? body.get("r").getAsInt() : 0; + int g = body.has("g") ? body.get("g").getAsInt() : 0; + int b = body.has("b") ? body.get("b").getAsInt() : 0; + int a = body.has("a") ? body.get("a").getAsInt() : 255; + return service.setNodeColor(body.get("id").getAsString(), r, g, b, a); + } + + if ("/appearance/node/size".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("id") || !body.has("size")) { + return errorResult("Missing 'id' or 'size'"); + } + return service.setNodeSize(body.get("id").getAsString(), body.get("size").getAsFloat()); + } + + if ("/appearance/edge/color".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) { + return errorResult("Missing 'source' or 'target'"); + } + int r = body.has("r") ? body.get("r").getAsInt() : 0; + int g = body.has("g") ? body.get("g").getAsInt() : 0; + int b = body.has("b") ? body.get("b").getAsInt() : 0; + int a = body.has("a") ? body.get("a").getAsInt() : 255; + return service.setEdgeColor(body.get("source").getAsString(), body.get("target").getAsString(), r, g, b, a); + } + + if ("/appearance/nodes/color".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("nodes")) { + return errorResult("Missing 'nodes' array"); + } + List> nodes = GSON.fromJson(body.get("nodes"), List.class); + return service.batchSetNodeColors(nodes); + } + + if ("/appearance/reset".equals(uri) && "POST".equals(method)) { + int r = body != null && body.has("r") ? body.get("r").getAsInt() : 153; + int g = body != null && body.has("g") ? body.get("g").getAsInt() : 153; + int b = body != null && body.has("b") ? body.get("b").getAsInt() : 153; + float size = body != null && body.has("size") ? body.get("size").getAsFloat() : 10f; + return service.resetAppearance(r, g, b, size); + } + + if ("/appearance/partition/color".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column")) { + return errorResult("Missing 'column'"); + } + String column = body.get("column").getAsString(); + Map colorMap = null; + if (body.has("colors") && body.get("colors").isJsonObject()) { + colorMap = new HashMap<>(); + JsonObject colors = body.getAsJsonObject("colors"); + for (String key : colors.keySet()) { + List rgb = GSON.fromJson(colors.get(key), List.class); + colorMap.put(key, new int[]{rgb.get(0).intValue(), rgb.get(1).intValue(), rgb.get(2).intValue()}); + } + } + return service.colorByPartition(column, colorMap); + } + + if ("/appearance/edge/partition-color".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column")) { + return errorResult("Missing 'column'"); + } + String column = body.get("column").getAsString(); + Map colorMap = null; + if (body.has("colors") && body.get("colors").isJsonObject()) { + colorMap = new HashMap<>(); + JsonObject colors = body.getAsJsonObject("colors"); + for (String key : colors.keySet()) { + List rgb = GSON.fromJson(colors.get(key), List.class); + colorMap.put(key, new int[]{rgb.get(0).intValue(), rgb.get(1).intValue(), rgb.get(2).intValue()}); + } + } + return service.colorEdgesByPartition(column, colorMap); + } + + if ("/appearance/ranking/color".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column")) { + return errorResult("Missing 'column'"); + } + String column = body.get("column").getAsString(); + int minRed = body.has("r_min") ? body.get("r_min").getAsInt() : 255; + int minGreen = body.has("g_min") ? body.get("g_min").getAsInt() : 255; + int minBlue = body.has("b_min") ? body.get("b_min").getAsInt() : 200; + int maxRed = body.has("r_max") ? body.get("r_max").getAsInt() : 255; + int maxGreen = body.has("g_max") ? body.get("g_max").getAsInt() : 0; + int maxBlue = body.has("b_max") ? body.get("b_max").getAsInt() : 0; + return service.colorByRanking(column, minRed, minGreen, minBlue, maxRed, maxGreen, maxBlue); + } + + if ("/appearance/ranking/size".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column")) { + return errorResult("Missing 'column'"); + } + float minSize = body.has("min_size") ? body.get("min_size").getAsFloat() : 5f; + float maxSize = body.has("max_size") ? body.get("max_size").getAsFloat() : 50f; + Double cap = body.has("cap") && !body.get("cap").isJsonNull() ? body.get("cap").getAsDouble() : null; + return service.sizeByRanking(body.get("column").getAsString(), minSize, maxSize, cap); + } + + // ─── Layout ────────────────────────────────────────────────── + + if ("/layout/run".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("algorithm")) { + return errorResult("Missing 'algorithm'"); + } + String algo = body.get("algorithm").getAsString(); + int iterations = body.has("iterations") ? body.get("iterations").getAsInt() : 1000; + // Inline properties: configure and run in one step. + if (body.has("properties") && body.get("properties").isJsonObject()) { + Map properties = GSON.fromJson(body.get("properties"), Map.class); + return service.runLayout(algo, iterations, properties); + } + return service.runLayout(algo, iterations); + } + + if ("/layout/stop".equals(uri) && "POST".equals(method)) { + return service.stopLayout(); + } + + if ("/layout/status".equals(uri) && "GET".equals(method)) { + return service.getLayoutStatus(); + } + + if ("/layout/available".equals(uri) && "GET".equals(method)) { + return service.getAvailableLayouts(); + } + + if ("/layout/properties".equals(uri) && "GET".equals(method)) { + String algo = params.get("algorithm"); + if (algo == null || algo.isEmpty()) { + return errorResult("Missing 'algorithm' parameter"); + } + return service.getLayoutProperties(algo); + } + + if ("/layout/properties".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("algorithm") || !body.has("properties")) { + return errorResult("Missing 'algorithm' or 'properties'"); + } + String algo = body.get("algorithm").getAsString(); + Map properties = GSON.fromJson(body.get("properties"), Map.class); + int iterations = body.has("iterations") ? body.get("iterations").getAsInt() : 1000; + return service.setLayoutProperties(algo, properties, iterations); + } + + // ─── Statistics ────────────────────────────────────────────── + + if ("/statistics/modularity".equals(uri) && "POST".equals(method)) { + double res = body != null && body.has("resolution") ? body.get("resolution").getAsDouble() : 1.0; + long timeoutMs = body != null && body.has("timeout_ms") ? body.get("timeout_ms").getAsLong() : 0; + return service.computeModularity(res, timeoutMs); + } + + if ("/statistics/stop".equals(uri) && "POST".equals(method)) { + return service.stopStatistics(); + } + + if ("/statistics/available".equals(uri) && "GET".equals(method)) { + return service.listStatistics(); + } + + if ("/statistics/run".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("name")) { + return errorResult("Missing 'name'"); + } + Map statParams = null; + if (body.has("params") && body.get("params").isJsonObject()) { + statParams = GSON.fromJson(body.get("params"), Map.class); + } + return service.runStatisticByName(body.get("name").getAsString(), statParams); + } + + if ("/statistics/degree".equals(uri) && "POST".equals(method)) { + return service.computeDegree(); + } + + if ("/statistics/betweenness".equals(uri) && "POST".equals(method)) { + return service.computeBetweenness(); + } + + if ("/statistics/pagerank".equals(uri) && "POST".equals(method)) { + return service.computePageRank(); + } + + if ("/statistics/connected-components".equals(uri) && "POST".equals(method)) { + return service.computeConnectedComponents(); + } + + if ("/statistics/clustering-coefficient".equals(uri) && "POST".equals(method)) { + return service.computeClusteringCoefficient(); + } + + if ("/statistics/avg-path-length".equals(uri) && "POST".equals(method)) { + return service.computeAvgPathLength(); + } + + if ("/statistics/hits".equals(uri) && "POST".equals(method)) { + return service.computeHITS(); + } + + if ("/statistics/eigenvector".equals(uri) && "POST".equals(method)) { + return service.computeEigenvectorCentrality(); + } + + // ─── Graph Operations ──────────────────────────────────────── + + if ("/graph/clear".equals(uri) && "POST".equals(method)) { + return service.clearGraph(); + } + + // ─── Filters ───────────────────────────────────────────────── + + if ("/filter/degree".equals(uri) && "POST".equals(method)) { + int min = body != null && body.has("min") ? body.get("min").getAsInt() : 0; + int max = body != null && body.has("max") ? body.get("max").getAsInt() : 0; + boolean dryRun = body != null && body.has("dry_run") && body.get("dry_run").getAsBoolean(); + return service.filterByDegreeRange(min, max, dryRun); + } + + if ("/filter/edge-weight".equals(uri) && "POST".equals(method)) { + double min = body != null && body.has("min") ? body.get("min").getAsDouble() : 0; + double max = body != null && body.has("max") ? body.get("max").getAsDouble() : 0; + boolean dryRun = body != null && body.has("dry_run") && body.get("dry_run").getAsBoolean(); + return service.filterByEdgeWeight(min, max, dryRun); + } + + if ("/filter/remove-isolates".equals(uri) && "POST".equals(method)) { + return service.removeIsolates(body != null && body.has("dry_run") && body.get("dry_run").getAsBoolean()); + } + + if ("/filter/ego-network".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("node_id")) { + return errorResult("Missing 'node_id'"); + } + String nodeId = body.get("node_id").getAsString(); + int depth = body.has("depth") ? body.get("depth").getAsInt() : 1; + return service.extractEgoNetwork(nodeId, depth); + } + + if ("/filter/giant-component".equals(uri) && "POST".equals(method)) { + return service.extractGiantComponent(); + } + + if ("/filter/reset".equals(uri) && "POST".equals(method)) { + return service.resetFilters(); + } + + if ("/filter/list".equals(uri) && "GET".equals(method)) { + return service.listFilters(); + } + + if ("/filter/apply".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("name")) { + return errorResult("Missing 'name'"); + } + String fname = body.get("name").getAsString(); + Map filterParams = body.has("params") ? GSON.fromJson(body.get("params"), Map.class) : null; + String action = body.has("action") ? body.get("action").getAsString() : "select"; + String column = body.has("column") ? body.get("column").getAsString() : null; + return service.applyFilter(fname, filterParams, action, column); + } + + if ("/filter/combine".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("filters") || !body.get("filters").isJsonArray()) { + return errorResult("Missing 'filters' list"); + } + List> specs = GSON.fromJson(body.get("filters"), List.class); + return service.applyFilters(specs, str(body, "combine"), str(body, "action"), str(body, "column"), + body.has("dry_run") && body.get("dry_run").getAsBoolean()); + } + + // ─── Time ──────────────────────────────────────────────────── + + if ("/time/from-columns".equals(uri) && "POST".equals(method)) { + if (body == null) { + return errorResult("Missing body"); + } + return service.setTimeFromColumns(str(body, "target"), str(body, "start"), str(body, "end"), + str(body, "date_format"), bool(body, "check_only")); + } + + if ("/time/slice".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("start") || !body.has("end")) { + return errorResult("Missing 'start' or 'end'"); + } + return service.timeSlice(body.get("start").getAsDouble(), body.get("end").getAsDouble()); + } + + // ─── Paths ─────────────────────────────────────────────────── + + if ("/graph/shortest-path".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("source") || !body.has("target")) { + return errorResult("Missing 'source' or 'target'"); + } + boolean follow = !body.has("follow_direction") || body.get("follow_direction").getAsBoolean(); + return service.findShortestPath(body.get("source").getAsString(), body.get("target").getAsString(), + str(body, "weighting"), follow, str(body, "mark_column")); + } + + // ─── Data Laboratory ───────────────────────────────────────── + + if ("/datalab/column/edit".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column") || !body.has("action")) { + return errorResult("Missing 'column' or 'action'"); + } + return service.editColumn(str(body, "target"), str(body, "column"), str(body, "action"), + str(body, "value"), str(body, "type"), str(body, "new_name"), bool(body, "check_only")); + } + + if ("/datalab/frequencies".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column")) { + return errorResult("Missing 'column'"); + } + String target = body.has("target") ? body.get("target").getAsString() : "node"; + return service.columnValueFrequencies(target, body.get("column").getAsString()); + } + + if ("/datalab/duplicates".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column")) { + return errorResult("Missing 'column'"); + } + String target = body.has("target") ? body.get("target").getAsString() : "node"; + boolean cs = body.has("case_sensitive") && body.get("case_sensitive").getAsBoolean(); + return service.detectDuplicates(target, body.get("column").getAsString(), cs); + } + + if ("/datalab/merge-nodes".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("ids")) { + return errorResult("Missing 'ids'"); + } + java.util.List ids = GSON.fromJson(body.get("ids"), java.util.List.class); + String into = body.has("into") ? body.get("into").getAsString() : null; + return service.mergeNodes(ids, into); + } + + if ("/datalab/regex-column".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("column") || !body.has("new_column") || !body.has("regex")) { + return errorResult("Missing 'column', 'new_column', or 'regex'"); + } + String target = body.has("target") ? body.get("target").getAsString() : "node"; + return service.createRegexColumn(target, body.get("column").getAsString(), + body.get("new_column").getAsString(), body.get("regex").getAsString()); + } + + // ─── Timeline (read-only) ──────────────────────────────────── + // NOTE: there is deliberately NO write endpoint here. Driving Gephi's + // timeline from outside (setInterval/setEnabled, or a time-derived + // setVisibleView) wedges the EDT, and Gephi's own shutdown runs on the + // EDT — so a wedged timeline op makes the app impossible to quit + // normally (Force Quit only). getTimeline is a pure read and is safe. + + if ("/timeline".equals(uri) && "GET".equals(method)) { + return service.getTimeline(); + } + + // ─── Edge Appearance ──────────────────────────────────────── + + if ("/appearance/edge/thickness-by-weight".equals(uri) && "POST".equals(method)) { + float minThickness = body != null && body.has("min_thickness") + ? body.get("min_thickness").getAsFloat() : 1f; + float maxThickness = body != null && body.has("max_thickness") + ? body.get("max_thickness").getAsFloat() : 5f; + return service.setEdgeThicknessByWeight(minThickness, maxThickness); + } + + // ─── Preview ───────────────────────────────────────────────── + + if ("/preview/settings".equals(uri) && "GET".equals(method)) { + return service.getPreviewSettings(); + } + + if ("/preview/settings".equals(uri) && "POST".equals(method)) { + if (body == null) { + return errorResult("Missing request body"); + } + // Body shape is flat {property: value}; unwrap the common client + // mistake of nesting everything under a "settings" key so it does + // not get stored as a junk preview property named "settings". + JsonObject effective = body; + if (body.size() == 1 && body.has("settings") && body.get("settings").isJsonObject()) { + effective = body.getAsJsonObject("settings"); + } + Map settings = GSON.fromJson(effective, Map.class); + return service.setPreviewSettings(settings); + } + + // ─── Export ────────────────────────────────────────────────── + + if ("/export/gexf".equals(uri) && "POST".equals(method)) { + // no "file" (or inline:true) -> return the GEXF as a string in "content" + if (body == null || !body.has("file") + || (body.has("inline") && body.get("inline").getAsBoolean())) { + return service.exportGexfContent(visibleBody(body, true)); + } + return service.exportGexf(body.get("file").getAsString(), visibleBody(body, true)); + } + + if ("/export/format".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file") || !body.has("format")) { + return errorResult("Missing 'file' or 'format'"); + } + return service.exportByFormat(body.get("file").getAsString(), body.get("format").getAsString(), + visibleBody(body, true)); + } + + if ("/export/png".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + String file = body.get("file").getAsString(); + int w = body.has("width") ? body.get("width").getAsInt() : 1920; + int h = body.has("height") ? body.get("height").getAsInt() : 1080; + return service.exportPng(file, w, h); + } + + if ("/export/screenshot".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + String file = body.get("file").getAsString(); + int scale = body.has("scale") ? body.get("scale").getAsInt() : 2; + boolean transparent = body.has("transparent_background") + && body.get("transparent_background").getAsBoolean(); + return service.exportScreenshot(file, scale, transparent); + } + + if ("/export/pdf".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.exportPdf(body.get("file").getAsString()); + } + + if ("/export/svg".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.exportSvg(body.get("file").getAsString()); + } + + if ("/export/graphml".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.exportGraphml(body.get("file").getAsString(), visibleBody(body, true)); + } + + if ("/export/csv".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + String file = body.get("file").getAsString(); + String separator = body.has("separator") ? body.get("separator").getAsString() : ","; + String target = body.has("target") ? body.get("target").getAsString() : "nodes"; + return service.exportCsv(file, separator, target); + } + + // ─── Import ────────────────────────────────────────────────── + + if ("/import/gexf".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.importFile(body.get("file").getAsString(), floatOrNull(body, "max_node_size"), + body.has("mode") ? body.get("mode").getAsString() : null); + } + + if ("/import/graphml".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.importFile(body.get("file").getAsString(), floatOrNull(body, "max_node_size"), + body.has("mode") ? body.get("mode").getAsString() : null); + } + + if ("/import/csv".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.importFile(body.get("file").getAsString(), floatOrNull(body, "max_node_size"), + body.has("mode") ? body.get("mode").getAsString() : null); + } + + if ("/import/file".equals(uri) && "POST".equals(method)) { + if (body == null || !body.has("file")) { + return errorResult("Missing 'file'"); + } + return service.importFile(body.get("file").getAsString(), floatOrNull(body, "max_node_size"), + body.has("mode") ? body.get("mode").getAsString() : null); + } + + return errorResult("Unknown endpoint: " + method + " " + uri); + } + + /** + * Reads an optional {@code visible} query parameter. When absent the endpoint keeps the + * view it has always used, so wiring this changes no existing caller. Every affected + * response also states which view it used and whether a filter is active, so the two + * can no longer disagree silently. + */ + static Double doubleParamOrNull(String v) { + if (v == null || v.isBlank()) { + return null; + } + try { + return Double.parseDouble(v.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + static boolean visibleParam(Map params, boolean dflt) { + String v = params == null ? null : params.get("visible"); + if (v == null) { + return dflt; + } + String t = v.trim(); + if (t.isEmpty()) { + return dflt; + } + if ("true".equalsIgnoreCase(t) || "1".equals(t)) { + return true; + } + if ("false".equalsIgnoreCase(t) || "0".equals(t)) { + return false; + } + // Anything else is not a decision. Falling back to the endpoint's default beats + // reading unrecognised text as "false", which would silently switch an export + // from the filtered graph to the whole graph on a typo. + return dflt; + } + + /** The {@code visible} flag from a JSON body, defaulting to the endpoint's historical view. */ + static boolean visibleBody(JsonObject body, boolean dflt) { + if (body == null || !body.has("visible") || body.get("visible").isJsonNull()) { + return dflt; + } + com.google.gson.JsonElement e = body.get("visible"); + // Only a real JSON boolean decides. Gson would read the string "banana" as false, + // which is the same silent-switch trap the query parameter avoids above. + if (e.isJsonPrimitive() && e.getAsJsonPrimitive().isBoolean()) { + return e.getAsBoolean(); + } + return dflt; + } + + /** + * The module's own version, read from the manifest the build generates from the POM. + * A literal here drifts from the POM the first time someone bumps one and not the other, + * and the drift is invisible until a user reports the wrong version. + */ + static String moduleVersion() { + try { + org.openide.modules.ModuleInfo info = + org.openide.modules.Modules.getDefault().ownerOf(GephiAPIServer.class); + if (info != null && info.getSpecificationVersion() != null) { + return info.getSpecificationVersion().toString(); + } + } catch (Throwable t) { + // Not running inside the platform (unit tests, or a future API change). + } + String fromPackage = GephiAPIServer.class.getPackage() == null + ? null : GephiAPIServer.class.getPackage().getImplementationVersion(); + return fromPackage != null ? fromPackage : "unknown"; + } + + /** + * The running Gephi's version: the specification version of its project API module, which + * Gephi releases carry as the Gephi version. Null when it cannot be read. Outside Gephi's + * module system (unit tests) it is read from that module's jar manifest. + */ + static String gephiVersion() { + Class api = org.gephi.project.api.ProjectController.class; + try { + org.openide.modules.ModuleInfo module = org.openide.modules.Modules.getDefault().ownerOf(api); + if (module != null && module.getSpecificationVersion() != null) { + return module.getSpecificationVersion().toString(); + } + } catch (Throwable ignore) { /* outside Gephi's module system */ + } + try { + java.net.URL jar = api.getProtectionDomain().getCodeSource().getLocation(); + try (java.util.jar.JarFile f = new java.util.jar.JarFile(new java.io.File(jar.toURI()))) { + return f.getManifest().getMainAttributes().getValue("OpenIDE-Module-Specification-Version"); + } + } catch (Exception ignore) { + return null; + } + } + + /** An optional float from a JSON body, or null when absent or not a number. */ + static boolean bool(JsonObject body, String key) { + return body != null && body.has(key) && !body.get(key).isJsonNull() && body.get(key).getAsBoolean(); + } + + static String str(JsonObject body, String key) { + return body != null && body.has(key) && !body.get(key).isJsonNull() ? body.get(key).getAsString() : null; + } + + static Float floatOrNull(JsonObject body, String key) { + if (body == null || !body.has(key) || body.get(key).isJsonNull()) { + return null; + } + try { + return body.get(key).getAsFloat(); + } catch (RuntimeException e) { + return null; + } + } + + private int parseIntParam(String value, int defaultValue) { + if (value == null) { + return defaultValue; + } + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private JsonObject errorResult(String message) { + JsonObject result = new JsonObject(); + result.addProperty("success", false); + result.addProperty("error", message); + return result; + } + + public void startServer() throws IOException { + httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + httpServer.createContext("/", this); + // Daemon threads: the listener must never keep the JVM alive on Gephi + // shutdown. With non-daemon threads, a missed/slow stop() would hang close. + executor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "Gephi-AI-HTTP"); + t.setDaemon(true); + return t; + }); + httpServer.setExecutor(executor); + httpServer.start(); + LOGGER.info("Gephi AI API started on http://127.0.0.1:" + getListeningPort()); + } + + /** + * Stops the HTTP listener only, leaving the shared {@link GephiControlService} singleton + * alone. Kept separate from {@link #stopServer()} so a test can tear down its own listener + * without shutting down state other tests in the suite may still rely on. + */ + public void stop() { + if (httpServer != null) { + // Up to one second for a request still being answered; the Installer bounds the whole stop at three. + httpServer.stop(1); + httpServer = null; + } + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + public void stopServer() { + stop(); + service.shutdown(); + LOGGER.info("Gephi AI API stopped"); + } + + /** True while the HTTP listener is bound and accepting connections. */ + public boolean isAlive() { + return httpServer != null; + } + + /** The bound port, or -1 when the server has not been started. */ + public int getListeningPort() { + HttpServer s = httpServer; + return s != null ? s.getAddress().getPort() : -1; + } +} diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/api/WelcomeCloser.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/api/WelcomeCloser.java new file mode 100644 index 000000000..682ff97f5 --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/api/WelcomeCloser.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Window; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.swing.SwingUtilities; + +/** + * Closes Gephi's Welcome window when work starts through the API, so it does not sit over the + * graph the assistant is building. Gephi closes it the same way, by disposing the window that + * holds its panel. Only in the first minutes after startup, and only until one has been closed: + * a Welcome the person reopens later from the Help menu is theirs. + */ +final class WelcomeCloser { + + static final String WELCOME_PANEL = "org.gephi.desktop.welcome.WelcomeTopComponent"; + static final long WINDOW_MS = 5 * 60_000; + + private static final Logger LOGGER = Logger.getLogger(WelcomeCloser.class.getName()); + private static final long STARTED = System.currentTimeMillis(); + private static volatile boolean closedOne; + + private WelcomeCloser() { + } + + /** Close the Welcome window if it is open and this is still the start of the session. */ + static void closeIfOpen() { + if (!shouldTry(System.currentTimeMillis() - STARTED, closedOne)) { + return; + } + SwingUtilities.invokeLater(() -> { + try { + for (Window window : Window.getWindows()) { + if (window.isShowing() && contains(window, WELCOME_PANEL)) { + window.dispose(); + closedOne = true; + } + } + } catch (RuntimeException e) { + LOGGER.log(Level.FINE, "Could not close the Welcome window", e); + } + }); + } + + static boolean shouldTry(long uptimeMs, boolean alreadyClosedOne) { + return !alreadyClosedOne && uptimeMs <= WINDOW_MS; + } + + /** True when a component of the named class sits anywhere inside {@code container}. */ + static boolean contains(Container container, String className) { + for (Component child : container.getComponents()) { + if (child.getClass().getName().equals(className)) { + return true; + } + if (child instanceof Container && contains((Container) child, className)) { + return true; + } + } + return false; + } +} diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java new file mode 100644 index 000000000..e8c806a4a --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java @@ -0,0 +1,6495 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.imageio.ImageIO; +import javax.swing.SwingUtilities; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.gephi.filters.api.FilterController; +import org.gephi.filters.api.Query; +import org.gephi.filters.spi.CategoryBuilder; +import org.gephi.filters.spi.Filter; +import org.gephi.filters.spi.FilterBuilder; +import org.gephi.filters.spi.FilterProperty; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphController; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.gephi.graph.api.Table; +import org.gephi.io.exporter.api.ExportController; +import org.gephi.io.exporter.preview.PDFExporter; +import org.gephi.io.exporter.preview.PNGExporter; +import org.gephi.io.exporter.spi.Exporter; +import org.gephi.io.exporter.spi.GraphExporter; +import org.gephi.io.importer.api.Container; +import org.gephi.io.importer.api.ImportController; +import org.gephi.io.processor.spi.Processor; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutBuilder; +import org.gephi.layout.spi.LayoutProperty; +import org.gephi.preview.api.PreviewController; +import org.gephi.preview.api.PreviewModel; +import org.gephi.preview.api.PreviewProperty; +import org.gephi.preview.types.DependantColor; +import org.gephi.preview.types.DependantOriginalColor; +import org.gephi.preview.types.EdgeColor; +import org.gephi.project.api.Project; +import org.gephi.project.api.ProjectController; +import org.gephi.project.api.Workspace; +import org.gephi.statistics.spi.Statistics; +import org.gephi.statistics.spi.StatisticsBuilder; +import org.gephi.statistics.spi.StatisticsUI; +import org.openide.util.Lookup; + +public class GephiControlService { + + private static final Logger LOGGER = Logger.getLogger(GephiControlService.class.getName()); + private static GephiControlService instance; + + // Config staged by setLayoutProperties (configure-only); the next runLayout of + // the same algorithm applies it. Lets set-then-run work without setLayoutProperties + // itself starting a layout. + private volatile Map pendingLayoutProps = null; + private volatile String pendingLayoutAlgo = null; + + // Human click journal: the person's node clicks in the Gephi window, + // recorded by a passive viz-event listener so the model can resolve + // "this one" / "these" to actual nodes. Bounded; strings only (never + // hold Node references — they outlive workspaces). + private static final int CLICK_JOURNAL_MAX = 50; + private final java.util.ArrayDeque clickJournal = new java.util.ArrayDeque<>(); + private volatile boolean clickListenerInstalled = false; + // Rectangle selection is turned on once per session so the human can box-select + // nodes for the agent to read without hunting for the toolbar tool. Set only + // after it actually succeeds (the view may not be started at the first attempt). + private volatile boolean rectangleAutoEnabled = false; + + private GephiControlService() { + } + + public static synchronized GephiControlService getInstance() { + if (instance == null) { + instance = new GephiControlService(); + } + return instance; + } + + // ─── Helpers ───────────────────────────────────────────────────── + + private ProjectController getProjectController() { + return Lookup.getDefault().lookup(ProjectController.class); + } + + private GraphController getGraphController() { + return Lookup.getDefault().lookup(GraphController.class); + } + + /** + * Project and workspace changes (new project, open, close, new, switch, delete, duplicate, + * rename) run on the calling HTTP thread, as Gephi's own interface runs them on a background + * thread: made on the interface thread they freeze it while listeners run, and Gephi warns + * that this will become an error. + */ + private static T onProjectThread(Callable work) { + try { + return work.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + private T runOnEDT(Callable callable) { + if (SwingUtilities.isEventDispatchThread()) { + try { + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + // Bounded wait: invokeAndWait parks forever when the EDT is wedged (the + // "health answers but nothing else does" symptom). Fail fast with guidance + // instead of hanging until the client's timeout. + final Object[] result = new Object[1]; + final Exception[] exception = new Exception[1]; + final java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + SwingUtilities.invokeLater(() -> { + try { + result[0] = callable.call(); + } catch (Exception e) { + exception[0] = e; + } finally { + done.countDown(); + } + }); + try { + if (!done.await(15, java.util.concurrent.TimeUnit.SECONDS)) { + throw new RuntimeException( + "Gephi's UI thread is unresponsive — the app is likely wedged; fully quit and reopen Gephi"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for Gephi's UI thread"); + } + if (exception[0] != null) { + throw new RuntimeException(exception[0]); + } + return (T) result[0]; + } + + static JsonObject success(String msg) { + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("message", msg); + return r; + } + + static JsonObject error(String msg) { + JsonObject r = new JsonObject(); + r.addProperty("success", false); + r.addProperty("error", msg); + return r; + } + + /** + * A request that failed with an exception: logs it with its stack trace, so a failure inside + * Gephi leaves a trace that can be reported, and returns the error to the caller. + */ + static JsonObject failure(String prefix, Exception e) { + LOGGER.log(Level.WARNING, prefix + e.getMessage(), e); + return error(prefix + e.getMessage()); + } + + private Workspace currentWorkspace() { + return getProjectController().getCurrentWorkspace(); + } + + private GraphModel currentGraphModel() { + Workspace ws = currentWorkspace(); + return ws != null ? getGraphController().getGraphModel(ws) : null; + } + + // ─── Write-lock acquisition (VizEngine-deadlock-safe) ──────────────── + + private static volatile java.lang.reflect.Field WRITE_LOCK_FIELD; + + /** + * Acquire the graph write lock with a bounded wait instead of the blocking writeLock(). + * If any thread leaves a read hold behind (an auto-locked iterator abandoned before it + * finished, in this plugin or in Gephi, releases its hold only on exhaustion), a blocking + * writeLock() waits forever and every reader queued behind it waits too, with no holder + * left for a thread dump to show. Short timed tryLock() attempts, retried for up to ~15s, + * turn that into a "graph busy" error the caller can report. graphstore's GraphLock has + * no timed acquisition, so the underlying ReentrantReadWriteLock is reached by reflection; + * if it cannot be, this falls back to the blocking lock. Once held, any Gephi-internal + * writeLock() on this same thread (setVisibleView, etc.) re-enters for free, which is why + * callers wrap those calls too. + */ + static void lockWrite(Graph g) { + try { + java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock wl = writeLockHandle(g); + if (wl == null) { + g.writeLock(); + return; + } + long deadline = System.nanoTime() + 15_000_000_000L; + while (!wl.tryLock(120, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (System.nanoTime() > deadline) { + throw new RuntimeException( + "Graph is busy (another task, such as a running statistic, holds the lock); please retry"); + } + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while acquiring the write lock"); + } catch (Throwable t) { + // Any other failure here (e.g. a classloading Error, which is not an Exception + // and would otherwise skip every catch(Exception) up the call chain and kill the + // HTTP connection with no response) must still surface as a normal API error. + throw new RuntimeException("Could not acquire write lock: " + t, t); + } + } + + /** Release the write lock taken by lockWrite. */ + static void unlockWrite(Graph g) { + g.writeUnlock(); + } + + private static volatile java.lang.reflect.Field READ_LOCK_FIELD; + + /* + * ITERATION RULE (wedge prevention): never iterate a live NodeIterable / + * EdgeIterable directly — always iterate .toArray(). A live iterator + * auto-acquires the graph read lock in its constructor and releases it only + * on exhaustion or doBreak(); an early break, return, or exception leaks the + * hold, and since nothing else ever unlocks on that thread's behalf, the leak is + * permanent and wedges every future write (found the hard way; see + * GraphOpsTest#earlyBreakOverToArraySnapshotLeavesNoReadHold). + */ + + /** + * Timed read-lock acquisition. Plain readLock() parks unboundedly in the lock's + * wait queue; when a writer is already parked (Gephi's own blocking writeLock()) + * every new reader queues behind it and the request hangs until the client's + * timeout — the chronic "health answers but nothing else does" symptom. A timed + * tryLock turns that into an immediate, actionable error instead. + */ + static void lockRead(Graph g) { + java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock rl = readLockHandle(g); + if (rl == null) { + g.readLock(); + return; + } + long deadline = System.nanoTime() + 10_000_000_000L; + try { + while (!rl.tryLock(120, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (System.nanoTime() > deadline) { + throw new RuntimeException( + "Graph is busy (lock unavailable) — if this persists, Gephi is wedged; fully quit and " + + "reopen it"); + } + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while acquiring the read lock"); + } + } + + /** The underlying ReentrantReadWriteLock.ReadLock behind Graph.getLock(), or null if unreachable. */ + static java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock readLockHandle(Graph g) { + try { + org.gephi.graph.api.GraphLock lock = g.getLock(); + if (lock == null) { + return null; + } + java.lang.reflect.Field f = READ_LOCK_FIELD; + if (f == null || !f.getDeclaringClass().isInstance(lock)) { + f = lock.getClass().getDeclaredField("readLock"); + f.setAccessible(true); + READ_LOCK_FIELD = f; + } + Object v = f.get(lock); + return (v instanceof java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock) + ? (java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock) v : null; + } catch (Throwable t) { + return null; + } + } + + /** The underlying ReentrantReadWriteLock.WriteLock behind Graph.getLock(), or null if unreachable. */ + static java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock writeLockHandle(Graph g) { + try { + org.gephi.graph.api.GraphLock lock = g.getLock(); + if (lock == null) { + return null; + } + java.lang.reflect.Field f = WRITE_LOCK_FIELD; + if (f == null || !f.getDeclaringClass().isInstance(lock)) { + f = lock.getClass().getDeclaredField("writeLock"); + f.setAccessible(true); + WRITE_LOCK_FIELD = f; + } + Object v = f.get(lock); + return (v instanceof java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock) + ? (java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock) v : null; + } catch (Throwable t) { + return null; + } + } + + /** Find an edge between two nodes, checking all edge types (directed type 1 and undirected type 0). */ + static Edge findEdge(Graph g, Node source, Node target) { + Edge e = g.getEdge(source, target, 1); // directed + if (e == null) { + e = g.getEdge(source, target, 0); // undirected + } + if (e == null) { + e = g.getEdge(source, target); // default + } + return e; + } + + /** + * Locate a layout builder by name (see bestLayoutMatch for the matching rules) and + * return a ready-to-use instance. + * + *

A freshly built layout has its properties at Java zero-values, NOT at Gephi's + * defaults — those live in {@code resetPropertiesValues()}, which the Gephi UI calls + * when you select a layout and which nothing here used to call. Layouts whose builder + * self-initializes (ForceAtlas 2) were fine; the rest silently ran on zeros. OpenOrd + * with {@code Layout Size} 0 collapsed every node onto (0,0), and Yifan Hu with + * {@code optimalDistance}/{@code stepRatio} 0 was a complete no-op that still reported + * success. Reset here so every layout starts from Gephi's real defaults and callers + * only need to pass the properties they actually want to change. + * + *

The graph model is attached first because size-dependent defaults read it + * (ForceAtlas 2 picks scalingRatio 2.0 vs 10.0 off the node count). + */ + private Layout findLayout(String algo) { + java.util.List builders = new java.util.ArrayList<>(); + java.util.List names = new java.util.ArrayList<>(); + for (LayoutBuilder b : Lookup.getDefault().lookupAll(LayoutBuilder.class)) { + builders.add(b); + names.add(b.getName()); + } + int idx = bestLayoutMatch(names, algo); + if (idx < 0) { + return null; + } + Layout layout = builders.get(idx).buildLayout(); + if (layout == null) { + return null; + } + // Separate failure paths: a missing graph model must not skip the reset, which is + // the part that actually keeps OpenOrd and Yifan Hu from running on zeros. + try { + GraphModel gm = currentGraphModel(); + if (gm != null) { + layout.setGraphModel(gm); + } + } catch (Exception e) { + LOGGER.log(Level.WARNING, "setGraphModel failed for layout: " + algo, e); + } + try { + layout.resetPropertiesValues(); + } catch (Exception e) { + // A layout that rejects the reset is still usable on its own defaults. + LOGGER.log(Level.WARNING, "resetPropertiesValues failed for layout: " + algo, e); + } + return layout; + } + + /** + * Index of the best layout-name match for {@code query}, or -1. An exact match wins + * (case- and space-insensitive, so the documented "forceatlas2" matches "ForceAtlas 2" + * and "yifanhu" matches "Yifan Hu"); otherwise the first substring match. Space-folding + * is what makes the short names in the docs/skill actually resolve. Package-private + + * static for unit testing without the layout registry. + */ + static int bestLayoutMatch(java.util.List names, String query) { + if (query == null) { + return -1; + } + String q = query.toLowerCase().trim(); + String qns = q.replace(" ", ""); + if (qns.isEmpty()) { + return -1; + } + int substr = -1; + for (int i = 0; i < names.size(); i++) { + String name = names.get(i); + if (name == null) { + continue; + } + String n = name.toLowerCase(); + String nns = n.replace(" ", ""); + if (n.equals(q) || nns.equals(qns)) { + return i; + } + if (substr == -1 && (n.contains(q) || nns.contains(qns))) { + substr = i; + } + } + return substr; + } + + // ─── Project Management ────────────────────────────────────────── + + /** + * Empties Gephi's Filters panel before its project closes. Closed off the interface + * thread, the project's filters are gone while the panel may still be drawing their + * entries, which fails inside Gephi; with the list emptied first there is nothing to draw. + * The project is being discarded, so nothing is lost. + */ + private void clearFilterQueriesBeforeClosing() { + ProjectController pc = getProjectController(); + if (!pc.hasCurrentProject()) { + return; + } + try { + runOnEDT(() -> { + FilterController fc = Lookup.getDefault().lookup(FilterController.class); + org.gephi.filters.api.FilterModel fm = fc == null ? null : fc.getModel(); + if (fm != null) { + for (Query q : fm.getQueries()) { + fc.remove(q); + } + } + return null; + }); + } catch (Exception e) { + LOGGER.log(Level.FINE, "Could not clear the filter list before closing the project", e); + } + } + + public JsonObject createProject(String name) { + clearFilterQueriesBeforeClosing(); + return onProjectThread(() -> { + ProjectController pc = getProjectController(); + pc.newProject(); + Workspace ws = pc.getCurrentWorkspace(); + JsonObject r = success("Project created"); + r.addProperty("workspace_id", ws != null ? ws.getId() : -1); + return r; + }); + } + + public JsonObject openProject(String filePath) { + File file = new File(filePath); + if (!file.exists()) { + return error("File not found: " + filePath); + } + try { + ProjectController pc = getProjectController(); + // Close any open project FIRST. Opening a .gephi on top of an existing + // project lands in a broken half-state where the graphstore never + // deserializes into a queryable model — the "open reports success but the + // graph is blank" bug. Verified: open works as the first action on a fresh + // instance and fails only when a project is already open; Gephi's own + // File>Open closes first. + if (pc.hasCurrentProject()) { + clearFilterQueriesBeforeClosing(); + onProjectThread(() -> { + pc.closeCurrentProject(); + return null; + }); + } + // openProject(File) off the EDT: it blocks on a LongTaskExecutor Future + // whose completion needs a free EDT. + pc.openProject(file); + } catch (Exception e) { + return failure("Failed to open project: ", e); + } + // Report the actual loaded counts so an empty result is never a silent success. + return runOnEDT(() -> { + JsonObject r = success("Project opened"); + Workspace cur = getProjectController().getCurrentWorkspace(); + int nodes = 0; + int edges = 0; + if (cur != null) { + Graph g = getGraphController().getGraphModel(cur).getGraph(); + nodes = g.getNodeCount(); + edges = g.getEdgeCount(); + } + r.addProperty("node_count", nodes); + r.addProperty("edge_count", edges); + if (nodes == 0) { + r.addProperty("warning", "opened but no nodes are in the current workspace"); + } + return r; + }); + } + + /** + * Saves the project and reports success only once the file is on disk. Gephi writes the + * file on the calling thread, so this runs off the interface thread to keep Gephi + * responsive; a save that failed or was cancelled leaves no new file, and says so. + */ + public JsonObject saveProject(String filePath) { + try { + ProjectController pc = getProjectController(); + Project project = pc.getCurrentProject(); + if (project == null) { + return error("No project open"); + } + File file = new File(filePath).getAbsoluteFile(); + // Checked first: a save that fails inside Gephi reports it in a dialog, which leaves + // this call waiting until someone closes it. + File dir = file.getParentFile(); + if (dir == null || !dir.isDirectory()) { + return error("The folder " + dir + " does not exist"); + } + if (!dir.canWrite() || (file.exists() && !file.canWrite())) { + return error("Gephi cannot write to " + file.getPath()); + } + long before = file.isFile() ? file.lastModified() : -1; + long started = System.currentTimeMillis(); + pc.saveProject(project, file); + String problem = savedFileProblem(file, before, started); + if (problem != null) { + return error(problem); + } + JsonObject r = success("Project saved"); + r.addProperty("file", file.getPath()); + r.addProperty("bytes", file.length()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** Null when {@code file} holds a fresh save, else what went wrong. */ + static String savedFileProblem(File file, long modifiedBefore, long started) { + String hint = " Gephi may have shown an error; check that the folder exists and is writable."; + if (!file.isFile()) { + return "The project was not saved: " + file.getPath() + " was not written." + hint; + } + if (file.length() == 0) { + return "The project was not saved: " + file.getPath() + " is empty." + hint; + } + // File times can be as coarse as two seconds, so only a file older than that counts as unchanged. + if (modifiedBefore >= 0 && file.lastModified() == modifiedBefore && modifiedBefore < started - 2000) { + return "The project was not saved: " + file.getPath() + " is unchanged." + hint; + } + return null; + } + + public JsonObject getProjectInfo() { + return runOnEDT(() -> { + Workspace ws = currentWorkspace(); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + if (ws != null) { + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + r.addProperty("has_project", true); + r.addProperty("workspace_id", ws.getId()); + r.addProperty("node_count", g.getNodeCount()); + r.addProperty("edge_count", g.getEdgeCount()); + r.addProperty("is_directed", gm.isDirected()); + r.addProperty("is_mixed", gm.isMixed()); + } else { + r.addProperty("has_project", false); + } + return r; + }); + } + + // ─── Workspace Management ──────────────────────────────────────── + + public JsonObject newWorkspace() { + return onProjectThread(() -> { + try { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + return error("No project open"); + } + Workspace ws = pc.newWorkspace(pc.getCurrentProject()); + pc.openWorkspace(ws); + JsonObject r = success("Workspace created"); + r.addProperty("workspace_id", ws.getId()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + }); + } + + public JsonObject listWorkspaces() { + return runOnEDT(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + return error("No project open"); + } + JsonArray arr = new JsonArray(); + Workspace current = pc.getCurrentWorkspace(); + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + JsonObject o = new JsonObject(); + o.addProperty("id", ws.getId()); + o.addProperty("name", ws.getName() != null ? ws.getName() : "Workspace " + ws.getId()); + o.addProperty("current", ws.equals(current)); + GraphModel gm = getGraphController().getGraphModel(ws); + if (gm != null) { + Graph g = gm.getGraph(); + o.addProperty("node_count", g.getNodeCount()); + o.addProperty("edge_count", g.getEdgeCount()); + } else { + o.addProperty("node_count", 0); + o.addProperty("edge_count", 0); + } + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("workspaces", arr); + return r; + }); + } + + public JsonObject switchWorkspace(int index) { + return onProjectThread(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + return error("No project open"); + } + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + pc.openWorkspace(ws); + return success("Switched to workspace " + ws.getId()); + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + public JsonObject deleteWorkspace(int index) { + return onProjectThread(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + return error("No project open"); + } + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + pc.deleteWorkspace(ws); + return success("Workspace deleted"); + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + public JsonObject duplicateWorkspace(int index) { + return onProjectThread(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + return error("No project open"); + } + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + try { + Workspace copy = pc.duplicateWorkspace(ws); + pc.openWorkspace(copy); + JsonObject r = success("Workspace duplicated"); + r.addProperty("workspace_id", copy.getId()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + public JsonObject renameWorkspace(int index, String name) { + return onProjectThread(() -> { + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + return error("No project open"); + } + int i = 0; + for (Workspace ws : pc.getCurrentProject().getWorkspaces()) { + if (i == index) { + try { + pc.renameWorkspace(ws, name); + return success("Workspace renamed to: " + name); + } catch (Exception e) { + return failure("Failed: ", e); + } + } + i++; + } + return error("Workspace index out of range: " + index); + }); + } + + // ─── Node Operations ───────────────────────────────────────────── + + public JsonObject addNode(String id, String label, Map attrs) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + return addNodeToModel(getGraphController().getGraphModel(ws), id, label, attrs); + } + + /** + * Core node-add against an explicit model. Package-private + static so it is testable with a standalone + * GraphModel. + */ + static JsonObject addNodeToModel(GraphModel gm, String id, String label, Map attrs) { + try { + Graph g = gm.getGraph(); + lockWrite(g); + try { + if (g.getNode(id) != null) { + return error("Node exists: " + id); + } + Node n = gm.factory().newNode(id); + n.setLabel(label != null ? label : id); + n.setX((float) (Math.random() * 1000 - 500)); + n.setY((float) (Math.random() * 1000 - 500)); + n.setSize(10f); + if (attrs != null) { + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + } + g.addNode(n); + JsonObject r = success("Node added"); + r.addProperty("node_id", id); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject addNodes(List> nodes) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + return addNodesToModel(getGraphController().getGraphModel(ws), nodes); + } + + /** Core batch node-add against an explicit model (applies per-node attributes). */ + static JsonObject addNodesToModel(GraphModel gm, List> nodes) { + try { + Graph g = gm.getGraph(); + int added = 0; + int skipped = 0; + lockWrite(g); + try { + for (Map nd : nodes) { + String id = (String) nd.get("id"); + if (id == null || g.getNode(id) != null) { + skipped++; + continue; + } + String label = (String) nd.getOrDefault("label", id); + Node n = gm.factory().newNode(id); + n.setLabel(label); + n.setX((float) (Math.random() * 1000 - 500)); + n.setY((float) (Math.random() * 1000 - 500)); + n.setSize(10f); + g.addNode(n); + Object attrsObj = nd.get("attributes"); + if (attrsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map attrs = (Map) attrsObj; + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + } + added++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("added", added); + r.addProperty("skipped", skipped); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject removeNode(String id) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = getGraphController().getGraphModel(ws).getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + int edgesRemoved = g.getDegree(n); + g.removeNode(n); + JsonObject r = success("Node removed"); + r.addProperty("edges_removed", edgesRemoved); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject bulkRemoveNodes(List ids) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = getGraphController().getGraphModel(ws).getGraph(); + lockWrite(g); + try { + int removed = 0; + int notFound = 0; + for (String id : ids) { + Node n = g.getNode(id); + if (n == null) { + notFound++; + continue; + } + g.removeNode(n); + removed++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("removed", removed); + r.addProperty("not_found", notFound); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject queryNodes(String attr, String val, int limit, int offset) { + return queryNodes(attr, val, limit, offset, false); + } + + public JsonObject queryNodes(String attr, String val, int limit, int offset, boolean visible) { + return queryNodes(attr, val, null, null, null, limit, offset, visible); + } + + /** + * Lists nodes, optionally only those whose {@code column} matches a value search (see + * nodeMatcher); {@code matches} then counts every match, not just the page returned. + * + * @param visible read the filtered visible graph instead of the full graph (see addViewInfo). + */ + public JsonObject queryNodes(String column, String value, String contains, Double min, Double max, + int limit, int offset, boolean visible) { + return queryNodes(column, value, contains, min, max, limit, offset, visible, null, true, null); + } + + /** + * As above, and with {@code sortBy} (a column id or title, or "degree") the matching nodes + * are ordered before paging, largest first unless {@code descending} is false. With + * {@code columns} (comma-separated ids or titles) each node carries only those attributes + * plus its id, label and degree, which keeps a long listing small. + */ + public JsonObject queryNodes(String column, String value, String contains, Double min, Double max, + int limit, int offset, boolean visible, String sortBy, + boolean descending, String columns) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = visible ? gm.getGraphVisible() : gm.getGraph(); + java.util.function.Predicate keep = null; + if (value != null || contains != null || min != null || max != null) { + if (column == null) { + return error("Name the column to search with 'column'"); + } + Column col = findColumn(gm.getNodeTable(), column); + if (col == null) { + return error("Column not found: " + column); + } + keep = nodeMatcher(col, value, contains, min, max); + } + Column sortCol = null; + if (sortBy != null && !"degree".equalsIgnoreCase(sortBy)) { + sortCol = findColumn(gm.getNodeTable(), sortBy); + if (sortCol == null) { + return error("Column not found for sort_by: " + sortBy); + } + } + java.util.Set wanted = wantedColumns(columns); + lockRead(g); + try { + JsonArray arr = new JsonArray(); + int count = 0; + int skip = 0; + int matches = 0; + // toArray, not the live iterable: breaking out of an auto-locked + // iterator before exhaustion leaks its read hold permanently. + List nodes = java.util.Arrays.asList(g.getNodes().toArray()); + if (sortBy != null) { + final Column sc = sortCol; + final Graph sg = g; + nodes = sortByValue(nodes, n -> sc == null ? (Object) sg.getDegree(n) : n.getAttribute(sc), + descending); + } + for (Node n : nodes) { + if (keep != null) { + if (!keep.test(n)) { + continue; + } + matches++; + } + if (skip++ < offset) { + continue; + } + if (count >= limit) { + if (keep == null) { + break; + } + continue; + } + JsonObject o = new JsonObject(); + o.addProperty("id", n.getId().toString()); + o.addProperty("label", n.getLabel()); + if (wanted == null) { + o.addProperty("x", n.x()); + o.addProperty("y", n.y()); + o.addProperty("size", n.size()); + } + o.addProperty("degree", g.getDegree(n)); + Color c = n.getColor(); + if (c != null && wanted == null) { + o.addProperty("r", c.getRed()); + o.addProperty("g", c.getGreen()); + o.addProperty("b", c.getBlue()); + o.addProperty("a", c.getAlpha()); + } + // Custom attributes: every one, or only those asked for + JsonObject attrs = new JsonObject(); + for (Column col : gm.getNodeTable()) { + if (col.isProperty()) { + continue; // skip built-in + } + if (!isWanted(wanted, col.getId(), col.getTitle())) { + continue; + } + Object v = n.getAttribute(col); + if (v != null) { + if (v instanceof Number) { + attrs.addProperty(col.getTitle(), (Number) v); + } else if (v instanceof Boolean) { + attrs.addProperty(col.getTitle(), (Boolean) v); + } else { + attrs.addProperty(col.getTitle(), v.toString()); + } + } + } + if (attrs.size() > 0) { + o.add("attributes", attrs); + } + arr.add(o); + count++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("total", g.getNodeCount()); + if (keep != null) { + r.addProperty("matches", matches); + } + if (sortBy != null) { + r.addProperty("sorted_by", sortCol == null ? "degree" : sortCol.getTitle()); + r.addProperty("descending", descending); + } + r.addProperty("count", count); + addViewInfo(r, gm, visible); + r.add("nodes", arr); + return r; + } finally { + g.readUnlock(); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** + * Items ordered by a value: numbers by size, anything else as text ignoring case. Items + * without a value always come last, so a sorted first page is the top of the graph. + */ + static List sortByValue(List items, java.util.function.Function value, + boolean descending) { + java.util.Comparator byValue = (a, b) -> { + if (a instanceof Number && b instanceof Number) { + return Double.compare(((Number) a).doubleValue(), ((Number) b).doubleValue()); + } + return a.toString().compareToIgnoreCase(b.toString()); + }; + java.util.Comparator order = descending ? byValue.reversed() : byValue; + List sorted = new java.util.ArrayList<>(items); + sorted.sort((x, y) -> { + Object a = value.apply(x); + Object b = value.apply(y); + if (a == null || b == null) { + return a == null ? (b == null ? 0 : 1) : -1; + } + return order.compare(a, b); + }); + return sorted; + } + + /** The requested attribute columns, lower-cased; null means every column. */ + static java.util.Set wantedColumns(String columns) { + if (columns == null || columns.isBlank()) { + return null; + } + java.util.Set wanted = new java.util.HashSet<>(); + for (String c : columns.split(",")) { + if (!c.isBlank()) { + wanted.add(c.trim().toLowerCase(java.util.Locale.ROOT)); + } + } + return wanted.isEmpty() ? null : wanted; + } + + static boolean isWanted(java.util.Set wanted, String id, String title) { + return wanted == null || wanted.contains(id.toLowerCase(java.util.Locale.ROOT)) + || (title != null && wanted.contains(title.toLowerCase(java.util.Locale.ROOT))); + } + + /** + * Which nodes a value search keeps: {@code value} matches the whole value (text ignoring + * case, numbers by value), {@code contains} a part of the text, and {@code min} / {@code max} + * a numeric range. The column is found by id or by title. Null when no search was asked for. + */ + static java.util.function.Predicate nodeMatcher(Column col, String value, String contains, + Double min, Double max) { + if (value == null && contains == null && min == null && max == null) { + return null; + } + String needle = contains == null ? null : contains.toLowerCase(java.util.Locale.ROOT); + return n -> { + Object v = n.getAttribute(col); + if (v == null) { + return false; + } + if (value != null) { + if (v instanceof Number) { + try { + if (((Number) v).doubleValue() != Double.parseDouble(value.trim())) { + return false; + } + } catch (NumberFormatException e) { + return false; + } + } else if (!v.toString().equalsIgnoreCase(value)) { + return false; + } + } + if (needle != null && !v.toString().toLowerCase(java.util.Locale.ROOT).contains(needle)) { + return false; + } + if (min != null || max != null) { + if (!(v instanceof Number)) { + return false; + } + double d = ((Number) v).doubleValue(); + if (min != null && d < min) { + return false; + } + if (max != null && d > max) { + return false; + } + } + return true; + }; + } + + public JsonObject getNode(String id) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = gm.getGraph(); + Node n = g.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + JsonObject o = new JsonObject(); + o.addProperty("id", n.getId().toString()); + o.addProperty("label", n.getLabel()); + o.addProperty("x", n.x()); + o.addProperty("y", n.y()); + o.addProperty("size", n.size()); + o.addProperty("r", (int) (n.r() * 255)); + o.addProperty("g", (int) (n.g() * 255)); + o.addProperty("b", (int) (n.b() * 255)); + JsonObject attrs = new JsonObject(); + for (Column col : gm.getNodeTable()) { + if (col.isProperty()) { + continue; + } + Object v = n.getAttribute(col); + if (v == null) { + continue; + } + if (v instanceof Number) { + attrs.addProperty(col.getTitle(), (Number) v); + } else if (v instanceof Boolean) { + attrs.addProperty(col.getTitle(), (Boolean) v); + } else { + attrs.addProperty(col.getTitle(), v.toString()); + } + } + o.add("attributes", attrs); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("node", o); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setNodeLabel(String id, String label) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + n.setLabel(label); + return success("Label set"); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setNodePosition(String id, float x, float y) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + n.setX(x); + n.setY(y); + return success("Position set"); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject batchSetPositions(List> positions) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + int set = 0; + int notFound = 0; + for (Map pos : positions) { + String id = (String) pos.get("id"); + Node n = g.getNode(id); + if (n == null) { + notFound++; + continue; + } + n.setX(((Number) pos.get("x")).floatValue()); + n.setY(((Number) pos.get("y")).floatValue()); + set++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("set", set); + r.addProperty("not_found", notFound); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Edge Operations ───────────────────────────────────────────── + + public JsonObject addEdge(String src, String tgt, Double weight, boolean directed) { + return addEdge(src, tgt, weight, directed, null); + } + + public JsonObject addEdge(String src, String tgt, Double weight, boolean directed, String edgeType) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + return addEdgeToModel(getGraphController().getGraphModel(ws), src, tgt, weight, directed, edgeType); + } + + /** Core edge-add against an explicit model. Type and directedness are kept consistent. */ + static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight, boolean directed) { + return addEdgeToModel(gm, src, tgt, weight, directed, null); + } + + /** + * Core edge-add, with an optional relationship type. When edgeType is null + * or blank the behavior is exactly as before: one edge per (source, target), + * type 0/1 by directedness. When edgeType is given, the edge is created under + * that named type (GraphStore's native typed parallel edges) and the + * duplicate check is scoped to that type — so A→B can carry a "cites" edge + * AND a "coauthor" edge at once, while a second "cites" A→B is still blocked. + */ + static JsonObject addEdgeToModel(GraphModel gm, String src, String tgt, Double weight, + boolean directed, String edgeType) { + try { + Graph g = gm.getGraph(); + lockWrite(g); + try { + Node s = g.getNode(src); + Node t = g.getNode(tgt); + if (s == null) { + return error("Source not found: " + src); + } + if (t == null) { + return error("Target not found: " + tgt); + } + double w = weight != null ? weight : 1.0; + if (edgeType != null && !edgeType.isEmpty()) { + int typeId = gm.addEdgeType(edgeType); + if (g.getEdge(s, t, typeId) != null) { + return error("Edge of type '" + edgeType + "' exists"); + } + g.addEdge(gm.factory().newEdge(s, t, typeId, w, directed)); + } else { + if (findEdge(g, s, t) != null) { + return error("Edge exists"); + } + g.addEdge(gm.factory().newEdge(s, t, directed ? 1 : 0, w, directed)); + } + return success("Edge added"); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject addEdges(List> edges) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + return addEdgesToModel(getGraphController().getGraphModel(ws), edges); + } + + /** Core batch edge-add against an explicit model (honors per-edge directed/label/attributes). */ + static JsonObject addEdgesToModel(GraphModel gm, List> edges) { + try { + Graph g = gm.getGraph(); + int added = 0; + int skipped = 0; + lockWrite(g); + try { + for (Map ed : edges) { + String src = (String) ed.get("source"); + String tgt = (String) ed.get("target"); + if (src == null || tgt == null) { + skipped++; + continue; + } + Node s = g.getNode(src); + Node t = g.getNode(tgt); + if (s == null || t == null) { + skipped++; + continue; + } + Double w = ed.containsKey("weight") ? ((Number) ed.get("weight")).doubleValue() : 1.0; + boolean directed = !ed.containsKey("directed") || Boolean.TRUE.equals(ed.get("directed")); + Object edgeTypeObj = ed.get("edge_type"); + String edgeType = edgeTypeObj != null ? edgeTypeObj.toString() : null; + int type; + if (edgeType != null && !edgeType.isEmpty()) { + type = gm.addEdgeType(edgeType); + if (g.getEdge(s, t, type) != null) { + skipped++; + continue; + } + } else { + if (findEdge(g, s, t) != null) { + skipped++; + continue; + } + type = directed ? 1 : 0; + } + Edge e = gm.factory().newEdge(s, t, type, w, directed); + Object label = ed.get("label"); + if (label != null) { + e.setLabel(label.toString()); + } + g.addEdge(e); + Object attrsObj = ed.get("attributes"); + if (attrsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map attrs = (Map) attrsObj; + for (Map.Entry en : attrs.entrySet()) { + ensureColumnAndSet(gm.getEdgeTable(), e, en.getKey(), en.getValue()); + } + } + added++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("added", added); + r.addProperty("skipped", skipped); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject removeEdge(String source, String target) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source); + Node t = g.getNode(target); + if (s == null || t == null) { + return error("Node not found"); + } + Edge e = findEdge(g, s, t); + if (e == null) { + return error("Edge not found"); + } + g.removeEdge(e); + return success("Edge removed"); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setEdgeWeight(String source, String target, double weight) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source); + Node t = g.getNode(target); + if (s == null || t == null) { + return error("Node not found"); + } + Edge e = findEdge(g, s, t); + if (e == null) { + return error("Edge not found"); + } + e.setWeight(weight); + return success("Weight set to " + weight); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setEdgeLabel(String source, String target, String label) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph g = currentGraphModel().getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source); + Node t = g.getNode(target); + if (s == null || t == null) { + return error("Node not found"); + } + Edge e = findEdge(g, s, t); + if (e == null) { + return error("Edge not found"); + } + e.setLabel(label); + return success("Edge label set"); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject queryEdges(int limit, int offset) { + return queryEdges(limit, offset, false); + } + + /** + * @param visible read the filtered visible graph instead of the full graph (see addViewInfo). + */ + public JsonObject queryEdges(int limit, int offset, boolean visible) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = visible ? gm.getGraphVisible() : gm.getGraph(); + lockRead(g); + try { + JsonArray arr = new JsonArray(); + int count = 0; + int skip = 0; + // toArray, not the live iterable: breaking out of an auto-locked + // iterator before exhaustion leaks its read hold permanently. + for (Edge e : g.getEdges().toArray()) { + if (skip++ < offset) { + continue; + } + if (count >= limit) { + break; + } + JsonObject o = new JsonObject(); + o.addProperty("source", e.getSource().getId().toString()); + o.addProperty("target", e.getTarget().getId().toString()); + o.addProperty("weight", e.getWeight()); + o.addProperty("directed", e.isDirected()); + if (e.getLabel() != null) { + o.addProperty("label", e.getLabel()); + } + Color c = e.getColor(); + if (c != null) { + o.addProperty("r", c.getRed()); + o.addProperty("g", c.getGreen()); + o.addProperty("b", c.getBlue()); + } + // Include custom attributes + JsonObject attrs = new JsonObject(); + for (Column col : gm.getEdgeTable()) { + if (col.isProperty()) { + continue; + } + Object v = e.getAttribute(col); + if (v != null) { + if (v instanceof Number) { + attrs.addProperty(col.getTitle(), (Number) v); + } else if (v instanceof Boolean) { + attrs.addProperty(col.getTitle(), (Boolean) v); + } else { + attrs.addProperty(col.getTitle(), v.toString()); + } + } + } + if (attrs.size() > 0) { + o.add("attributes", attrs); + } + arr.add(o); + count++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("total", g.getEdgeCount()); + r.addProperty("count", count); + addViewInfo(r, gm, visible); + r.add("edges", arr); + return r; + } finally { + g.readUnlock(); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Graph Stats ───────────────────────────────────────────────── + + /** + * Read/export view consistency. File and inline exports historically write the + * VISIBLE (filtered) graph while every read endpoint reads the FULL graph — so with + * a filter active, /graph/stats could report 5000 nodes while /export/gexf silently + * wrote 200, and every consumer of the inline GEXF computed over a graph the stats + * never described. The defaults are kept (changing them would silently change every + * existing client), but no response is silent about it any more: each one carries + * {@code view} ("full" | "visible") naming the view it was computed from and + * {@code filter_active}; whenever a filter IS active it also carries + * {@code full_node_count}/{@code full_edge_count} and + * {@code visible_node_count}/{@code visible_edge_count} so the discrepancy is + * visible to the caller. The {@code visible} overloads let the HTTP layer expose an + * explicit choice of view per request. + */ + private static void addViewInfo(JsonObject r, GraphModel gm, boolean visibleView) { + boolean filterActive = !gm.getVisibleView().isMainView(); + r.addProperty("view", visibleView ? "visible" : "full"); + r.addProperty("filter_active", filterActive); + if (filterActive) { + Graph full = gm.getGraph(); + Graph vis = gm.getGraphVisible(); + r.addProperty("full_node_count", full.getNodeCount()); + r.addProperty("full_edge_count", full.getEdgeCount()); + r.addProperty("visible_node_count", vis.getNodeCount()); + r.addProperty("visible_edge_count", vis.getEdgeCount()); + } + } + + public JsonObject getGraphStats() { + return getGraphStats(false); + } + + /** + * @param visible read the filtered visible graph instead of the full graph (see addViewInfo). + */ + public JsonObject getGraphStats(boolean visible) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = getGraphController().getGraphModel(ws); + Graph g = visible ? gm.getGraphVisible() : gm.getGraph(); + lockRead(g); + try { + int nc = g.getNodeCount(); + int ec = g.getEdgeCount(); + double density = nc > 1 ? (2.0 * ec) / (nc * (nc - 1)) : 0; + double avgDeg = nc > 0 ? (2.0 * ec) / nc : 0; + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("node_count", nc); + r.addProperty("edge_count", ec); + r.addProperty("density", density); + r.addProperty("average_degree", avgDeg); + r.addProperty("is_directed", gm.isDirected()); + addViewInfo(r, gm, visible); + return r; + } finally { + g.readUnlock(); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Graph Type ────────────────────────────────────────────────── + + public JsonObject getGraphType() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("directed", gm.isDirected()); + r.addProperty("undirected", gm.isUndirected()); + r.addProperty("mixed", gm.isMixed()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Attribute / Column Management ─────────────────────────────── + + public JsonObject getColumns(String target) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + Table table = "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable(); + JsonArray arr = new JsonArray(); + for (Column col : table) { + JsonObject o = new JsonObject(); + o.addProperty("id", col.getId()); + o.addProperty("title", col.getTitle()); + o.addProperty("type", col.getTypeClass().getSimpleName()); + o.addProperty("property", col.isProperty()); + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("target", target); + r.add("columns", arr); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject addColumn(String name, String type, String target) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + return addColumnToModel(currentGraphModel(), name, type, target); + } + + /** + * Add a column under the graph write lock. Taking the lock matters for ordering: + * ensureColumnAndSet() also adds columns while holding the write lock, so doing it + * lock-free here created an A-holds-graph/wants-column vs B-holds-column/wants-graph + * deadlock under concurrent requests. Package-private + static for unit testing. + */ + static JsonObject addColumnToModel(GraphModel gm, String name, String type, String target) { + try { + Table table = "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable(); + Class cls = typeStringToClass(type); + if (cls == null) { + return error("Unknown type: " + type + ". Use: string, integer, double, float, boolean, long"); + } + Graph g = gm.getGraph(); + lockWrite(g); + try { + Column existing = findColumn(table, name); + if (existing != null) { + return error("Column already exists: " + existing.getTitle() + " (id " + existing.getId() + ")"); + } + table.addColumn(name, cls); + } finally { + unlockWrite(g); + } + return success("Column '" + name + "' added"); + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setNodeAttributes(String id, Map attrs) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + Node n = g.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + return success("Attributes set on node " + id); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject batchSetNodeAttributes(List> updates) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + int set = 0; + int notFound = 0; + for (Map update : updates) { + String id = (String) update.get("id"); + Node n = g.getNode(id); + if (n == null) { + notFound++; + continue; + } + @SuppressWarnings("unchecked") + Map attrs = (Map) update.get("attributes"); + if (attrs != null) { + for (Map.Entry e : attrs.entrySet()) { + ensureColumnAndSet(gm.getNodeTable(), n, e.getKey(), e.getValue()); + } + } + set++; + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("set", set); + r.addProperty("not_found", notFound); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setEdgeAttributes(String source, String target, Map attrs) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + Node s = g.getNode(source); + Node t = g.getNode(target); + if (s == null || t == null) { + return error("Node not found"); + } + Edge e = findEdge(g, s, t); + if (e == null) { + return error("Edge not found"); + } + for (Map.Entry entry : attrs.entrySet()) { + ensureColumnAndSet(gm.getEdgeTable(), e, entry.getKey(), entry.getValue()); + } + return success("Attributes set on edge"); + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + static void ensureColumnAndSet(Table table, Object element, String key, Object value) { + Column col = findColumn(table, key); + if (col == null) { + Class cls = String.class; + if (value instanceof Number) { + if (value instanceof Integer) { + cls = Integer.class; + } else if (value instanceof Long) { + cls = Long.class; + } else if (value instanceof Float) { + cls = Float.class; + } else { + cls = Double.class; + } + } else if (value instanceof Boolean) { + cls = Boolean.class; + } + col = table.addColumn(key, cls); + } + // Convert value to column type + Object converted = convertToColumnType(value, col.getTypeClass()); + if (element instanceof Node) { + ((Node) element).setAttribute(col, converted); + } else if (element instanceof Edge) { + ((Edge) element).setAttribute(col, converted); + } + } + + static Object convertToColumnType(Object value, Class targetType) { + if (value == null) { + return null; + } + if (targetType.isInstance(value)) { + return value; + } + String s = value.toString(); + try { + if (targetType == Integer.class) { + return (int) Double.parseDouble(s); + } + if (targetType == Long.class) { + return (long) Double.parseDouble(s); + } + if (targetType == Float.class) { + return (float) Double.parseDouble(s); + } + if (targetType == Double.class) { + return Double.parseDouble(s); + } + if (targetType == Boolean.class) { + return Boolean.parseBoolean(s); + } + } catch (Exception e) { /* fall through */ + } + return s; + } + + static Class typeStringToClass(String type) { + if (type == null) { + return null; + } + switch (type.toLowerCase()) { + case "string": + return String.class; + case "integer": + case "int": + return Integer.class; + case "double": + return Double.class; + case "float": + return Float.class; + case "boolean": + case "bool": + return Boolean.class; + case "long": + return Long.class; + default: + return null; + } + } + + // ─── Appearance: Individual Node/Edge Styling ──────────────────── + + /** + * Node color/size are plain fields on the Node object (NodeImpl.setColor/setSize just + * write an int/float, no checkWriteLock) — unlike addNode/removeNode, which mutate + * NodeStore's internal structure and do enforce the write lock. Gephi's own + * AppearanceController.transform() (see colorByPartition/colorByRanking/sizeByRanking + * below) sets these same properties on every node in the graph under nothing more than + * a live NodeIterable's read lock, so a single-node set needs no write lock either. + */ + public JsonObject setNodeColor(String id, int r, int g, int b, int a) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph graph = currentGraphModel().getGraph(); + Node n = graph.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + n.setColor(new Color(r, g, b, a)); + return success("Node color set"); + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setNodeSize(String id, float size) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph graph = currentGraphModel().getGraph(); + Node n = graph.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + n.setSize(size); + return success("Node size set to " + size); + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /* + * THREADING NOTE (applies to every styling/filter method below that once wrapped its + * body in runOnEDT): these operations mutate the graph model, which is thread-safe + * under its own lock and does not need the EDT. Polling lockWrite's 15-second tryLock + * loop ON the EDT froze the UI under contention, tripped runOnEDT's own 15-second + * timeout (misreporting "Gephi's UI thread is unresponsive"), and — worse — the + * abandoned EDT task still ran later, applying a destructive mutation after the HTTP + * call had already reported failure, so a client retry applied it twice. They now run + * on the calling thread, like clearGraph and addNodeToModel always have. + */ + + public JsonObject setEdgeColor(String source, String target, int r, int g, int b, int a) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph graph = currentGraphModel().getGraph(); + lockWrite(graph); + try { + Node s = graph.getNode(source); + Node t = graph.getNode(target); + if (s == null || t == null) { + return error("Node not found"); + } + Edge e = findEdge(graph, s, t); + if (e == null) { + return error("Edge not found"); + } + e.setColor(new Color(r, g, b, a)); + return success("Edge color set"); + } finally { + unlockWrite(graph); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject batchSetNodeColors(List> nodeColors) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Graph graph = currentGraphModel().getGraph(); + int set = 0; + int notFound = 0; + for (Map nc : nodeColors) { + String id = (String) nc.get("id"); + Node n = graph.getNode(id); + if (n == null) { + notFound++; + continue; + } + int r = ((Number) nc.get("r")).intValue(); + int g = ((Number) nc.get("g")).intValue(); + int b = ((Number) nc.get("b")).intValue(); + int a = nc.containsKey("a") ? ((Number) nc.get("a")).intValue() : 255; + n.setColor(new Color(r, g, b, a)); + set++; + } + JsonObject res = new JsonObject(); + res.addProperty("success", true); + res.addProperty("set", set); + res.addProperty("not_found", notFound); + return res; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject resetAppearance(int r, int g, int b, float size) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph graph = currentGraphModel().getGraph(); + Color defaultColor = new Color(r, g, b); + // toArray, not the live iterable: breaking out of an auto-locked iterator + // before exhaustion leaks its read hold permanently (see ITERATION RULE above). + for (Node n : graph.getNodes().toArray()) { + n.setColor(defaultColor); + n.setSize(size); + } + return success("Appearance reset for all nodes"); + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Appearance: Color/Size by Attribute ───────────────────────── + + /** + * A column by its id, or by the title users see in Gephi (any case) when no id matches. + * Imported files often give columns internal ids such as "0" with a readable title. + */ + static Column findColumn(org.gephi.graph.api.Table table, String name) { + if (table == null || name == null) { + return null; + } + Column byId = table.getColumn(name); + if (byId != null) { + return byId; + } + // Search a copy: iterating the table itself locks it until the loop runs to the end, so + // returning from inside the loop would leave the table locked and wedge Gephi. + for (Column c : table.toArray()) { + if (name.equalsIgnoreCase(c.getId()) || name.equalsIgnoreCase(c.getTitle())) { + return c; + } + } + return null; + } + + private static int clamp255(int v) { + return Math.max(0, Math.min(255, v)); + } + + /** + * Give a partition the exact colours Gephi AI applied, matched by the value's text as the + * colour map is keyed. Values Gephi AI left alone keep their colour in the panel. Returns how + * many values were set. + */ + static int applyPaletteToPartition(org.gephi.appearance.api.Partition partition, Graph graph, + Map palette) { + int set = 0; + for (Object value : partition.getValues(graph)) { + Color c = palette.get(String.valueOf(value)); + if (c != null) { + partition.setColor(value, c); + set++; + } + } + return set; + } + + /** How many visible nodes (or edges) have a value in {@code col} that {@code counts}. */ + static int countVisible(GraphModel gm, boolean edges, java.util.function.Predicate counts, Column col) { + Graph visible = gm.getGraphVisible(); + lockRead(visible); + try { + int n = 0; + for (org.gephi.graph.api.Element e : edges ? visible.getEdges().toArray() : visible.getNodes().toArray()) { + if (counts.test(e.getAttribute(col))) { + n++; + } + } + return n; + } finally { + visible.readUnlock(); + } + } + + /** A two-stop colour ranking from the minimum colour to the maximum, as Gephi AI applies it. */ + static void configureRankingColor(org.gephi.appearance.plugin.RankingElementColorTransformer t, + Color min, Color max) { + t.setColors(new Color[]{min, max}); + t.setColorPositions(new float[]{0f, 1f}); + } + + /** A size ranking from the minimum size to the maximum, as Gephi AI applies it. */ + static void configureRankingSize(org.gephi.appearance.plugin.RankingSizeTransformer t, + float min, float max) { + t.setMinSize(min); + t.setMaxSize(max); + } + + /** + * Apply a colour or size the way the Appearance panel's Apply button does: take Gephi's + * function for {@code col}, give it the colours or sizes through {@code configure}, and + * transform the visible graph with it. The ranking and partition scales are global, so a + * filter does not rescale the values. Returns the function, or null when Gephi offers no + * such function for the column. + */ + private org.gephi.appearance.api.Function applyAppearance(Workspace ws, Column col, boolean edges, + Class transformer, + java.util.function.Consumer configure) { + org.gephi.appearance.api.AppearanceController ac = + Lookup.getDefault().lookup(org.gephi.appearance.api.AppearanceController.class); + org.gephi.appearance.api.AppearanceModel am = ac == null ? null : ac.getModel(ws); + if (am == null) { + return null; + } + org.gephi.appearance.api.Function f = edges + ? am.getEdgeFunction(col, transformer) : am.getNodeFunction(col, transformer); + if (f == null) { + return null; + } + ac.setUseRankingLocalScale(false); + ac.setUsePartitionLocalScale(false); + configure.accept(f); + ac.transform(f); + return f; + } + + private static JsonObject noAppearanceFunction(String what, Column col) { + return error("Gephi's Appearance panel offers no " + what + " for column '" + col.getTitle() + "'"); + } + + /** + * Set Gephi's Appearance panel to the node function Gephi AI just applied (nodes, the + * transformer's category, its UI, the column), so the panel shows what was done and Apply + * there reproduces it. Returns null when the panel shows it, or the reason it could not, + * without failing the caller. + */ + private String showInAppearancePanel(org.gephi.appearance.api.Function f) { + try { + org.gephi.desktop.appearance.AppearanceUIController ui = + Lookup.getDefault().lookup(org.gephi.desktop.appearance.AppearanceUIController.class); + if (ui == null) { + return "Gephi's Appearance panel is not available"; + } + onEdt(() -> { + ui.setSelectedElementClass("nodes"); + ui.setSelectedCategory(f.getUI().getCategory()); + ui.setSelectedTransformerUI(f.getUI()); + // Selecting the function already shown does not refresh the panel, so clear it + // first; otherwise the panel keeps the colours it had before. + ui.setSelectedFunction(null); + ui.setSelectedFunction(f); + }); + return null; + } catch (Exception e) { + LOGGER.log(Level.FINE, "Could not show the appearance in Gephi's Appearance panel", e); + return "could not update Gephi's Appearance panel: " + e; + } + } + + /** Record in a response whether Gephi's Appearance panel now shows what was applied. */ + private static void reportPanel(JsonObject r, String problem) { + r.addProperty("appearance_panel", problem == null); + if (problem != null) { + r.addProperty("appearance_panel_note", problem); + } + } + + /** + * Eight colours validated for categorical use on light backgrounds (lightness band, chroma + * floor, contrast), given to the largest groups first. On a map any two groups can touch, so + * the order keeps every pair of the first five apart with normal vision and under simulated + * red and green colour blindness; past five, groups need labels as well as colour. The skill + * documents the same eight, with a variant for dark backgrounds. + */ + static final Color[] BASE_PALETTE = { + new Color(42, 120, 214), new Color(237, 161, 0), new Color(0, 131, 0), + new Color(232, 123, 164), new Color(74, 58, 167), new Color(227, 73, 72), + new Color(27, 175, 122), new Color(235, 104, 52) + }; + + /** + * A colour per value, largest group first, so the most distinct colours go to the groups + * that cover most of the map. Past the eight base colours every further group still gets + * its own colour: hues step by the golden angle, and saturation and brightness alternate so + * neighbouring hues stay apart. + */ + static java.util.LinkedHashMap partitionPalette(Map counts) { + List> order = new java.util.ArrayList<>(counts.entrySet()); + order.sort((a, b) -> b.getValue().equals(a.getValue()) + ? a.getKey().compareTo(b.getKey()) : Integer.compare(b.getValue(), a.getValue())); + java.util.LinkedHashMap palette = new java.util.LinkedHashMap<>(); + int i = 0; + for (Map.Entry e : order) { + palette.put(e.getKey(), paletteColor(i++)); + } + return palette; + } + + static Color paletteColor(int i) { + if (i < BASE_PALETTE.length) { + return BASE_PALETTE[i]; + } + int k = i - BASE_PALETTE.length; + float hue = (float) ((0.13 + k * 0.618033988749895) % 1.0); + float saturation = new float[] {0.55f, 0.85f, 0.40f}[k % 3]; + float brightness = new float[] {0.85f, 0.60f, 0.95f}[(k / 3) % 3]; + return Color.getHSBColor(hue, saturation, brightness); + } + + /** Past this many groups, some pairs of colours are hard to tell apart where groups touch. */ + static final int DISTINCT_GROUPS = 5; + + static void addPaletteNote(JsonObject r, int groups) { + if (groups > DISTINCT_GROUPS) { + r.addProperty("palette_note", groups + " groups each have their own colour, but past " + + DISTINCT_GROUPS + " some colours are hard to tell apart where groups touch," + + " especially for colour-blind readers. Label the groups as well, or colour only" + + " the largest and leave the rest gray."); + } + } + + public JsonObject colorByPartition(String columnName, Map colorMap) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = findColumn(gm.getNodeTable(), columnName); + if (col == null) { + return error("Column not found: " + columnName); + } + + // Collect distinct values + java.util.Map palette = new java.util.LinkedHashMap<>(); + if (colorMap != null && !colorMap.isEmpty()) { + for (Map.Entry e : colorMap.entrySet()) { + int[] c = e.getValue(); + palette.put(e.getKey(), new Color(c[0], c[1], c[2])); + } + } else { + java.util.Map counts = new java.util.HashMap<>(); + for (Node n : graph.getNodes().toArray()) { + Object v = n.getAttribute(col); + if (v != null) { + counts.merge(v.toString(), 1, Integer::sum); + } + } + palette.putAll(partitionPalette(counts)); + } + + org.gephi.appearance.api.Function f = applyAppearance(ws, col, false, + org.gephi.appearance.plugin.PartitionElementColorTransformer.class, + fn -> applyPaletteToPartition(((org.gephi.appearance.api.PartitionFunction) fn).getPartition(), + fn.getGraph(), palette)); + if (f == null) { + return noAppearanceFunction("partition colouring", col); + } + int colored = countVisible(gm, false, v -> v != null && palette.containsKey(v.toString()), col); + JsonObject r = success("Colored " + colored + " nodes by " + columnName); + r.addProperty("partitions", palette.size()); + addPaletteNote(r, palette.size()); + addViewInfo(r, gm, true); + reportPanel(r, showInAppearancePanel(f)); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** + * Min and max over the numeric values of {@code col}, as {@code [min, max]}, or null when + * the column holds no numeric values. Seeded with infinities so a column whose values are + * entirely negative ranks correctly — the old {@code Double.MIN_VALUE} seed (smallest + * positive double) silently broke that case. Package-private + static for unit testing. + */ + static double[] numericRange(Graph g, Column col) { + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + lockRead(g); + try { + for (Node n : g.getNodes().toArray()) { + Object v = n.getAttribute(col); + if (v instanceof Number) { + double d = ((Number) v).doubleValue(); + if (d < min) { + min = d; + } + if (d > max) { + max = d; + } + } + } + } finally { + g.readUnlock(); + } + return min == Double.POSITIVE_INFINITY ? null : new double[]{min, max}; + } + + /** + * Column lookup for ranking operations. When a degree column is requested + * before the degree statistic has run (the #1 cold-start stumble), computes + * it on the spot instead of failing. + * + *

Must be called OFF the EDT: runStatistic executes the statistic (statistics + * dispatch UI work to the EDT internally — see extractGiantComponent) and renders + * its report, which for Degree is a JFreeChart image. colorByRanking and + * sizeByRanking call this from the HTTP thread, never inside a runOnEDT hop. + */ + private Column resolveRankingColumn(GraphModel gm, String columnName) { + Column col = findColumn(gm.getNodeTable(), columnName); + if (col == null && columnName != null) { + String lc = columnName.toLowerCase(); + if (lc.equals("degree") || lc.equals("indegree") || lc.equals("outdegree")) { + runStatistic("Degree", null); + col = findColumn(gm.getNodeTable(), columnName); + } + } + return col; + } + + private static JsonObject columnNotFound(String columnName) { + return error("Column not found: " + columnName + + " — compute the metric first (degree, pagerank, betweenness, modularity" + + " via the statistics tools) or check the columns list"); + } + + public JsonObject colorByRanking(String columnName, int minRed, int minGreen, int minBlue, int maxRed, + int maxGreen, int maxBlue) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = resolveRankingColumn(gm, columnName); + if (col == null) { + return columnNotFound(columnName); + } + + double[] mm = numericRange(graph, col); + if (mm == null) { + return error("No numeric values in column " + columnName); + } + final Color low = new Color(clamp255(minRed), clamp255(minGreen), clamp255(minBlue)); + final Color high = new Color(clamp255(maxRed), clamp255(maxGreen), clamp255(maxBlue)); + org.gephi.appearance.api.Function f = applyAppearance(ws, col, false, + org.gephi.appearance.plugin.RankingElementColorTransformer.class, + fn -> configureRankingColor(fn.getTransformer(), low, high)); + if (f == null) { + return noAppearanceFunction("colour ranking", col); + } + int colored = countVisible(gm, false, v -> v instanceof Number, col); + JsonObject res = success("Colored " + colored + " nodes by ranking on " + columnName); + res.addProperty("min_value", mm[0]); + res.addProperty("max_value", mm[1]); + addViewInfo(res, gm, true); + reportPanel(res, showInAppearancePanel(f)); + return res; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** + * The size a node with value {@code v} gets on a ranking from {@code min} to {@code max}. + * With a {@code cap} below {@code max}, values at or above the cap get the largest size and + * the rest spread over the whole range, so a few outliers cannot shrink every other node. + */ + static float rankedSize(double v, double min, double max, Double cap, float minSize, float maxSize) { + double top = cap != null && cap < max ? cap : max; + double range = top - min; + if (range <= 0) { + range = 1; + } + double t = Math.min(1.0, Math.max(0.0, (v - min) / range)); + return (float) (minSize + t * (maxSize - minSize)); + } + + public JsonObject sizeByRanking(String columnName, float minSize, float maxSize) { + return sizeByRanking(columnName, minSize, maxSize, null); + } + + public JsonObject sizeByRanking(String columnName, float minSize, float maxSize, Double cap) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = resolveRankingColumn(gm, columnName); + if (col == null) { + return columnNotFound(columnName); + } + + double[] mm = numericRange(graph, col); + if (mm == null) { + return error("No numeric values in column " + columnName); + } + double min = mm[0]; + double max = mm[1]; + boolean capped = cap != null && cap < max; + + if (!capped) { + org.gephi.appearance.api.Function f = applyAppearance(ws, col, false, + org.gephi.appearance.plugin.RankingNodeSizeTransformer.class, + fn -> configureRankingSize(fn.getTransformer(), minSize, maxSize)); + if (f == null) { + return noAppearanceFunction("size ranking", col); + } + int sized = countVisible(gm, false, v -> v instanceof Number, col); + JsonObject res = success("Sized " + sized + " nodes by " + columnName); + res.addProperty("min_value", min); + res.addProperty("max_value", max); + addViewInfo(res, gm, true); + reportPanel(res, showInAppearancePanel(f)); + return res; + } + + // Gephi's size ranking has no cap, so a capped ranking is applied here directly. + int sized = 0; + int atCap = 0; + lockWrite(graph); + try { + for (Node n : graph.getNodes().toArray()) { + Object v = n.getAttribute(col); + if (v instanceof Number) { + double value = ((Number) v).doubleValue(); + n.setSize(rankedSize(value, min, max, cap, minSize, maxSize)); + if (capped && value >= cap) { + atCap++; + } + sized++; + } + } + } finally { + unlockWrite(graph); + } + JsonObject res = success("Sized " + sized + " nodes by " + columnName); + res.addProperty("min_value", min); + res.addProperty("max_value", max); + res.addProperty("cap", cap); + res.addProperty("nodes_at_cap", atCap); + addViewInfo(res, gm, false); + reportPanel(res, "Gephi's Appearance panel has no cap, so it was left as it was;" + + " reapplying the ranking there would undo the cap."); + return res; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Layout ────────────────────────────────────────────────────── + + public JsonObject runLayout(String algo, int iterations) { + return runLayout(algo, iterations, null); + } + + public JsonObject runLayout(String algo, int iterations, Map properties) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = getGraphController().getGraphModel(ws); + Layout layout = findLayout(algo); + if (layout == null) { + return error("Layout not found: " + algo); + } + layout.setGraphModel(gm); + // Apply inline properties, or config staged earlier by setLayoutProperties. + if (properties == null && pendingLayoutProps != null && algo.equals(pendingLayoutAlgo)) { + properties = pendingLayoutProps; + } + pendingLayoutProps = null; + pendingLayoutAlgo = null; + org.gephi.layout.api.LayoutController lc = layoutController(); + if (lc == null) { + return error("Gephi's layout controller is not available"); + } + if (lc.getModel().isRunning()) { + return error("Layout already running"); + } + final int panelIters = iterations > 0 ? iterations : 1000; + final java.util.List unapplied = + startLayoutThroughController(lc, layout, properties, panelIters, this::onEdt); + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("layout", algo); + r.addProperty("status", "running"); + reportUnapplied(r, unapplied, algo); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject stopLayout() { + org.gephi.layout.api.LayoutController lc = layoutController(); + if (lc == null || !lc.getModel().isRunning()) { + return success("No layout running"); + } + lc.stopLayout(); + return success("Layout stopped"); + } + + public JsonObject getLayoutStatus() { + JsonObject r = new JsonObject(); + r.addProperty("success", true); + org.gephi.layout.api.LayoutController lc = layoutController(); + boolean running = lc != null && lc.getModel().isRunning(); + r.addProperty("running", running); + if (running && lc.getModel().getSelectedBuilder() != null) { + r.addProperty("layout", lc.getModel().getSelectedBuilder().getName()); + } + return r; + } + + public JsonObject getAvailableLayouts() { + JsonArray arr = new JsonArray(); + for (LayoutBuilder b : Lookup.getDefault().lookupAll(LayoutBuilder.class)) { + JsonObject o = new JsonObject(); + o.addProperty("name", b.getName()); + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("layouts", arr); + return r; + } + + public JsonObject getLayoutProperties(String algo) { + try { + Layout layout = findLayout(algo); + if (layout == null) { + return error("Layout not found: " + algo); + } + // Need a graph model for the layout to report properties + Workspace ws = currentWorkspace(); + if (ws != null) { + layout.setGraphModel(currentGraphModel()); + } + + JsonArray arr = new JsonArray(); + LayoutProperty[] props = layout.getProperties(); + if (props != null) { + for (LayoutProperty prop : props) { + JsonObject o = new JsonObject(); + o.addProperty("name", prop.getCanonicalName() != null ? prop.getCanonicalName() + : prop.getProperty().getDisplayName()); + o.addProperty("display_name", prop.getProperty().getDisplayName()); + o.addProperty("type", prop.getProperty().getValueType().getSimpleName()); + Object val = prop.getProperty().getValue(); + if (val != null) { + o.addProperty("value", val.toString()); + } + String desc = prop.getProperty().getShortDescription(); + if (desc != null) { + o.addProperty("description", desc); + } + arr.add(o); + } + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("algorithm", algo); + r.add("properties", arr); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** + * Apply layout properties by canonical key, display name or full canonical name (any case). + * Returns the keys that matched no property, in the order given, so a misspelled setting is + * reported instead of the layout silently running on its default. + */ + static java.util.List applyLayoutProperties(Layout layout, Map properties) { + java.util.List unapplied = new java.util.ArrayList<>(); + if (properties == null) { + return unapplied; + } + java.util.Set matched = new java.util.HashSet<>(); + LayoutProperty[] props = layout.getProperties(); + if (props == null) { + unapplied.addAll(properties.keySet()); + return unapplied; + } + for (LayoutProperty prop : props) { + String canonicalName = prop.getCanonicalName() != null ? prop.getCanonicalName() : ""; + String displayName = prop.getProperty().getDisplayName(); + // Extract middle key from "AlgoName.propertyKey.name" pattern + String canonicalKey = ""; + if (!canonicalName.isEmpty()) { + String[] parts = canonicalName.split("\\."); + if (parts.length >= 3) { + canonicalKey = parts[parts.length - 2]; + } + } + Object val = null; + for (Map.Entry e : properties.entrySet()) { + String k = e.getKey(); + if ((!canonicalKey.isEmpty() && k.equalsIgnoreCase(canonicalKey)) + || k.equalsIgnoreCase(displayName) + || (!canonicalName.isEmpty() && k.equalsIgnoreCase(canonicalName))) { + matched.add(k); + if (val == null) { + val = e.getValue(); + } + } + } + if (val != null) { + Class type = prop.getProperty().getValueType(); + Object converted = convertLayoutProperty(val, type); + if (converted != null) { + try { + prop.getProperty().setValue(converted); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Set layout property failed", e); + } + } + } + } + for (String k : properties.keySet()) { + if (!matched.contains(k)) { + unapplied.add(k); + } + } + return unapplied; + } + + /** Adds unapplied_params and a warning to a layout response when any key matched nothing. */ + private static void reportUnapplied(JsonObject r, java.util.List unapplied, String algo) { + if (unapplied == null || unapplied.isEmpty()) { + return; + } + JsonArray ua = new JsonArray(); + for (String k : unapplied) { + ua.add(k); + } + r.add("unapplied_params", ua); + r.addProperty("warning", "These settings match no property of " + algo + + " and were NOT applied: " + unapplied + ". Check the names with gephi_get_layout_properties."); + } + + /** + * Configure a layout's properties WITHOUT running it. The config is staged so + * the next runLayout of the same algorithm applies it — set-then-run works, + * and this call no longer hijacks the layout executor (which broke a following + * run_layout with "Layout already running"). Prefer run_layout(properties=...) + * to configure and run in one step. + */ + public JsonObject setLayoutProperties(String algo, Map properties, int iterations) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + Layout layout = findLayout(algo); + if (layout == null) { + return error("Layout not found: " + algo); + } + layout.setGraphModel(currentGraphModel()); + final java.util.List unapplied = applyLayoutProperties(layout, properties); + pendingLayoutProps = properties; + pendingLayoutAlgo = algo; + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("layout", algo); + r.addProperty("configured", true); + r.addProperty("running", false); + r.addProperty("note", "properties staged; the next run_layout of this algorithm applies them"); + reportUnapplied(r, unapplied, algo); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + static Object convertLayoutProperty(Object val, Class type) { + if (val == null) { + return null; + } + String s = val.toString(); + try { + if (type == Boolean.class || type == boolean.class) { + return Boolean.parseBoolean(s); + } + if (type == Integer.class || type == int.class) { + return (int) Double.parseDouble(s); + } + if (type == Double.class || type == double.class) { + return Double.parseDouble(s); + } + if (type == Float.class || type == float.class) { + return (float) Double.parseDouble(s); + } + if (type == Long.class || type == long.class) { + return (long) Double.parseDouble(s); + } + if (type == String.class) { + return s; + } + } catch (Exception e) { /* fall through */ + } + return null; + } + + // ─── Statistics ────────────────────────────────────────────────── + + /** + * Every statistic available in this Gephi instance — built-ins plus any + * installed plugin that registers a StatisticsBuilder (verified with the + * CWTS Leiden plugin). Names here are what /statistics/run accepts. + */ + public JsonObject listStatistics() { + JsonArray arr = new JsonArray(); + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + JsonObject o = new JsonObject(); + o.addProperty("name", sb.getName()); + try { + o.addProperty("id", sb.getStatistics().getClass().getSimpleName()); + } catch (Throwable t) { /* name alone is enough */ + } + arr.add(o); + } + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("statistics", arr); + return r; + } + + /** Run any available statistic by name — the plugin-ecosystem passthrough. */ + public JsonObject runStatisticByName(String name, Map params) { + return runStatistic(name, params); + } + + private static final org.gephi.utils.progress.ProgressTicket NOOP_TICKET = + new org.gephi.utils.progress.ProgressTicket() { + public void finish() { + } + + public void finish(String s) { + } + + public void progress() { + } + + public void progress(int i) { + } + + public void progress(String s) { + } + + public void progress(String s, int i) { + } + + public String getDisplayName() { + return "MCP statistic"; + } + + public void setDisplayName(String s) { + } + + public void start() { + } + + public void start(int i) { + } + + public void switchToDeterminate(int i) { + } + + public void switchToIndeterminate() { + } + }; + + /** Cancels statistics that overrun their deadline. One daemon thread serves every run. */ + private static final java.util.concurrent.ScheduledExecutorService DEADLINES = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "Gephi AI statistic deadline"); + t.setDaemon(true); + return t; + }); + + /** + * Run {@code work}, cancelling {@code task} if it is still running after {@code timeoutMs}. + * Returns true when the deadline stopped it. A timeout of 0 or less runs without a deadline. + * Gephi's modularity can loop forever (gephi#1630); its loop checks the cancel flag, so this + * ends the run and releases the graph lock it holds. + */ + static boolean runWithDeadline(Runnable work, org.gephi.utils.longtask.spi.LongTask task, + long timeoutMs) { + if (timeoutMs <= 0) { + work.run(); + return false; + } + java.util.concurrent.atomic.AtomicBoolean fired = new java.util.concurrent.atomic.AtomicBoolean(); + java.util.concurrent.ScheduledFuture deadline = DEADLINES.schedule(() -> { + fired.set(true); + task.cancel(); + }, timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS); + try { + work.run(); + } finally { + deadline.cancel(false); + } + return fired.get(); + } + + /** + * A dynamic statistic steps through the network's timeline window by window. On a network + * with no time data there is no timeline to step through and Gephi's loop never ends, so it + * is refused. Returns the reason, or null when the statistic can run. + */ + private static String fmtTime(double t) { + return t == Math.rint(t) ? String.valueOf((long) t) : String.valueOf(t); + } + + static String dynamicStatisticProblem(Statistics stat, GraphModel gm) { + if (!(stat instanceof org.gephi.statistics.spi.DynamicStatistics) || gm == null) { + return null; + } + org.gephi.graph.api.Interval bounds = gm.isDynamic() ? gm.getTimeBounds() : null; + if (bounds == null || Double.isInfinite(bounds.getLow()) || Double.isInfinite(bounds.getHigh())) { + return "This is a dynamic statistic and needs a network with time data (timestamps or" + + " intervals on nodes or edges). This network has none, so it was not run."; + } + // Gephi's dynamic statistics start with window 0 and tick 0, which its settings dialog + // fills in. Gephi steps from the start by tick while a window fits, so tick 0 never ends + // and a window wider than the span computes nothing. + org.gephi.statistics.spi.DynamicStatistics dyn = (org.gephi.statistics.spi.DynamicStatistics) stat; + double span = bounds.getHigh() - bounds.getLow(); + String range = " The network's time data runs from " + fmtTime(bounds.getLow()) + " to " + + fmtTime(bounds.getHigh()) + "; pass params {\"window\": ..., \"tick\": ...} in those units."; + if (!(dyn.getTick() > 0)) { + return "This dynamic statistic needs a tick (the step between windows) greater than 0." + range; + } + if (dyn.getWindow() < 0 || dyn.getWindow() > span) { + return "This dynamic statistic needs a window (the width of each time slice) between 0" + + " and the network's time span." + range; + } + return null; + } + + /** A statistic with no deadline is waited for this long before it is reported as still running. */ + static final long STATISTIC_WAIT_CAP_MS = 60L * 60 * 1000; + /** After a deadline cancels a statistic, how long to wait for it to actually stop. */ + static final long STATISTIC_STOP_GRACE_MS = 30_000; + + /** Statistics running now, by the name Gephi shows for them, so a stop request can reach them. */ + static final Map RUNNING_STATISTICS = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Records a statistic as running, unless a run of the same statistic is already under way. + * Two runs of one statistic do the same work twice and hold the graph's read lock for both, + * which keeps every write waiting longer. Returns null when claimed, or the error to report. + */ + static String claimStatistic(Statistics stat, String name) { + synchronized (RUNNING_STATISTICS) { + if (RUNNING_STATISTICS.containsValue(name)) { + return name + " is already running; wait for it to finish, or stop it with stop_statistic"; + } + RUNNING_STATISTICS.put(stat, name); + return null; + } + } + + /** Statistics stopped by request, so their run is reported as stopped rather than finished. */ + static final java.util.Set STOP_REQUESTED = java.util.concurrent.ConcurrentHashMap.newKeySet(); + + /** + * Stops every statistic Gephi AI started that is still running. A stopped run writes + * nothing, so the columns keep their earlier values. Statistics that offer no way to stop + * them are named, so the user knows to wait or to stop them from Gephi. + */ + public JsonObject stopStatistics() { + JsonArray stopped = new JsonArray(); + JsonArray unstoppable = new JsonArray(); + for (Map.Entry e : new java.util.ArrayList<>(RUNNING_STATISTICS.entrySet())) { + if (e.getKey() instanceof org.gephi.utils.longtask.spi.LongTask) { + STOP_REQUESTED.add(e.getKey()); + ((org.gephi.utils.longtask.spi.LongTask) e.getKey()).cancel(); + stopped.add(e.getValue()); + } else { + unstoppable.add(e.getValue()); + } + } + JsonObject r = success(stopped.size() + unstoppable.size() == 0 ? "No statistic is running" + : stopped.size() > 0 ? "Stopped " + stopped.size() + " statistic(s)" + : "The running statistic cannot be stopped; it will finish on its own"); + r.add("stopped", stopped); + if (unstoppable.size() > 0) { + r.add("cannot_stop", unstoppable); + } + return r; + } + + /** + * Run a statistic through Gephi's Statistics panel when the desktop interface is present, so + * the panel shows it running, its result and its report, as if the user had clicked Run. + * Without the panel (headless, tests) the statistic runs directly. Returns true when the + * deadline stopped it; throws when Gephi reports a failure or the run never finishes. + */ + static boolean executeStatistic(Statistics stat, GraphModel gm, long timeoutMs, + org.gephi.desktop.statistics.api.StatisticsControllerUI panel, + java.util.function.Consumer onEdt) throws InterruptedException { + if (panel == null) { + if (stat instanceof org.gephi.utils.longtask.spi.LongTask) { + return runWithDeadline(() -> stat.execute(gm), + (org.gephi.utils.longtask.spi.LongTask) stat, timeoutMs); + } + stat.execute(gm); + return false; + } + java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference failure = + new java.util.concurrent.atomic.AtomicReference<>(); + org.gephi.utils.longtask.api.LongTaskListener listener = + new org.gephi.utils.longtask.api.LongTaskListener() { + @Override + public void taskFinished(org.gephi.utils.longtask.spi.LongTask task) { + done.countDown(); + } + + // Gephi 0.11.3+ reports a failed run here (and shows the user a dialog); earlier + // versions never call it, and the wait below still ends at its limit. + public void fatalError(Throwable t) { + failure.set(t); + done.countDown(); + } + }; + onEdt.accept(() -> panel.execute(stat, listener)); + boolean stopped = false; + long wait = timeoutMs > 0 ? timeoutMs : STATISTIC_WAIT_CAP_MS; + if (!done.await(wait, java.util.concurrent.TimeUnit.MILLISECONDS)) { + if (timeoutMs <= 0 || !(stat instanceof org.gephi.utils.longtask.spi.LongTask)) { + throw new IllegalStateException("The statistic is still running in Gephi after " + + (wait / 60000) + " minutes; stop it from Gephi's Statistics panel."); + } + ((org.gephi.utils.longtask.spi.LongTask) stat).cancel(); + stopped = true; + if (!done.await(STATISTIC_STOP_GRACE_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("The statistic was stopped at its deadline but has" + + " not finished; restart Gephi if it keeps running."); + } + } + if (failure.get() != null) { + throw new RuntimeException(failure.get()); + } + return stopped; + } + + /** + * Start a layout through Gephi's LayoutController, so the Layout panel shows the algorithm, + * the settings used and the running state. Selecting a layout reloads the settings Gephi + * saved for it, so the settings go on after selection, starting from the layout's defaults + * as a directly run layout does; selecting it again makes the panel show them. Returns the + * setting names that matched no property. + */ + static java.util.List startLayoutThroughController(org.gephi.layout.api.LayoutController lc, + Layout layout, Map properties, + int iterations, + java.util.function.Consumer onEdt) { + java.util.List unapplied = new java.util.ArrayList<>(); + onEdt.accept(() -> { + lc.setLayout(layout); + try { + layout.resetPropertiesValues(); + } catch (Exception e) { + LOGGER.log(Level.WARNING, "resetPropertiesValues failed for " + layout, e); + } + unapplied.addAll(applyLayoutProperties(layout, properties)); + lc.setLayout(layout); + lc.executeLayout(iterations); + }); + return unapplied; + } + + private void onEdt(Runnable r) { + runOnEDT(() -> { + r.run(); + return null; + }); + } + + private static org.gephi.layout.api.LayoutController layoutController() { + org.gephi.layout.api.LayoutController lc = + Lookup.getDefault().lookup(org.gephi.layout.api.LayoutController.class); + return lc != null && lc.getModel() != null ? lc : null; + } + + private JsonObject runStatistic(String builderName, Map params) { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + final GraphModel gm = currentGraphModel(); + + // Find statistics builder by name + StatisticsBuilder matchedBuilder = null; + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + String name = sb.getName(); + LOGGER.fine("MCP: Found StatisticsBuilder: " + name + " (" + sb.getClass().getName() + ")"); + if (name.equalsIgnoreCase(builderName) + || sb.getClass().getSimpleName().toLowerCase().contains(builderName.toLowerCase())) { + matchedBuilder = sb; + break; + } + } + if (matchedBuilder == null) { + // Also try matching by statistics class name + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + try { + Statistics stat = sb.getStatistics(); + if (stat.getClass().getSimpleName().equalsIgnoreCase(builderName)) { + matchedBuilder = sb; + break; + } + } catch (Exception e) { /* skip */ + } + } + } + if (matchedBuilder == null) { + return error("Statistics not found: " + builderName); + } + + Statistics stat = matchedBuilder.getStatistics(); + + // Set parameters via reflection; collect the ones that did not land so a + // mistyped name is reported instead of silently ignored (a typo used to be + // indistinguishable from a correctly-parameterised run). + java.util.List unappliedParams = new java.util.ArrayList<>(); + long timeoutMs = 0; + if (params != null && params.get("timeout_ms") instanceof Number) { + params = new java.util.HashMap<>(params); + timeoutMs = ((Number) params.remove("timeout_ms")).longValue(); + } + if (params != null) { + for (Map.Entry e : params.entrySet()) { + if (!setViaReflection(stat, e.getKey(), e.getValue())) { + unappliedParams.add(e.getKey()); + } + } + } + + // Plugin statistics are often LongTasks that assume the UI gave them a + // progress ticket and call it without null checks (e.g. CWTS Leiden). + // Provide a no-op ticket so they run outside the statistics dialog. + if (stat instanceof org.gephi.utils.longtask.spi.LongTask) { + ((org.gephi.utils.longtask.spi.LongTask) stat).setProgressTicket(NOOP_TICKET); + } + + // Execute, stopping it at the deadline when one was given + String dynamicProblem = dynamicStatisticProblem(stat, gm); + if (dynamicProblem != null) { + return error(dynamicProblem); + } + boolean stopped; + boolean stoppedOnRequest; + String busy = claimStatistic(stat, matchedBuilder.getName()); + if (busy != null) { + return error(busy); + } + try { + stopped = executeStatistic(stat, gm, timeoutMs, + Lookup.getDefault().lookup(org.gephi.desktop.statistics.api.StatisticsControllerUI.class), + this::onEdt); + } finally { + RUNNING_STATISTICS.remove(stat); + stoppedOnRequest = STOP_REQUESTED.remove(stat); + } + if (stoppedOnRequest) { + JsonObject r = error(matchedBuilder.getName() + " was stopped before it finished;" + + " its column was not updated."); + r.addProperty("stopped", true); + return r; + } + if (stopped) { + // A cancelled run writes nothing, so the column still holds the previous run. + JsonObject r = error(matchedBuilder.getName() + " did not finish within " + + (timeoutMs / 1000) + " s and was stopped; its column was not updated." + + ("Modularity".equals(builderName) + ? " Gephi's modularity occasionally never converges (gephi#1630);" + + " running it again usually finishes normally." : "")); + r.addProperty("stopped", true); + return r; + } + + // Build result + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.addProperty("statistic", matchedBuilder.getName()); + if (!unappliedParams.isEmpty()) { + JsonArray ua = new JsonArray(); + for (String k : unappliedParams) { + ua.add(k); + } + r.add("unapplied_params", ua); + r.addProperty("warning", "Parameters matched no setter or field on " + + stat.getClass().getSimpleName() + " and were NOT applied: " + unappliedParams); + } + + // Try to get common result values via reflection + tryAddResult(r, stat, "getModularity", "modularity"); + tryAddResult(r, stat, "getAverageDegree", "average_degree"); + tryAddResult(r, stat, "getPathLength", "average_path_length"); + tryAddResult(r, stat, "getDiameter", "diameter"); + tryAddResult(r, stat, "getRadius", "radius"); + tryAddResult(r, stat, "getAverageClusteringCoefficient", "average_clustering_coefficient"); + tryAddResult(r, stat, "getConnectedComponentsCount", "connected_components"); + // The line Gephi's Statistics panel shows for this run. Display text, possibly + // rounded; its use is a headline for statistics with no getter above, such as + // those from other Gephi plugins. + String shown = panelResult(stat, Lookup.getDefault().lookupAll(StatisticsUI.class)); + if (shown != null) { + r.addProperty("panel_result", shown); + } + + // Get the report + try { + String report = stat.getReport(); + if (report != null) { + r.addProperty("report_available", true); + r.addProperty("report_html", report); + } + } catch (Exception e) { /* no report */ + } + + return r; + } catch (Exception e) { + LOGGER.log(Level.WARNING, "Statistic execution failed", e); + return error("Failed: " + e.getMessage()); + } + } + + /** + * Set {@code setter} on {@code obj} via a JavaBeans setter or, failing that, a bare + * field of the same (case-insensitive) name. Returns true only when a value was + * actually applied; callers surface the false case so a mistyped parameter name is + * distinguishable from a correctly-configured run. + */ + private boolean setViaReflection(Object obj, String setter, Object value) { + String methodName = "set" + setter.substring(0, 1).toUpperCase() + setter.substring(1); + try { + for (java.lang.reflect.Method m : obj.getClass().getMethods()) { + if (m.getName().equals(methodName) && m.getParameterCount() == 1) { + Class paramType = m.getParameterTypes()[0]; + Object converted = convertStatValue(value, paramType); + if (converted == null) { + return false; // name matched, value did not convert + } + m.invoke(obj, converted); + return true; + } + } + // No setter: plugin statistics (e.g. the CWTS Leiden plugin) often use + // bare fields configured by their UI panel — set the field directly. + for (Class c = obj.getClass(); c != null && c != Object.class; c = c.getSuperclass()) { + for (java.lang.reflect.Field f : c.getDeclaredFields()) { + if (f.getName().equalsIgnoreCase(setter)) { + Object converted = convertStatValue(value, f.getType()); + if (converted == null) { + return false; + } + f.setAccessible(true); + f.set(obj, converted); + return true; + } + } + } + } catch (Exception e) { + LOGGER.fine("Could not set " + methodName + ": " + e.getMessage()); + } + return false; + } + + /** Value conversion for statistic parameters: layout-style primitives plus enums by name. */ + static Object convertStatValue(Object val, Class type) { + if (val != null && type.isEnum()) { + String want = val.toString(); + for (Object ec : type.getEnumConstants()) { + if (ec.toString().equalsIgnoreCase(want)) { + return ec; + } + } + return null; + } + return convertLayoutProperty(val, type); + } + + /** + * The result line the statistic's own UI gives for this run (what Gephi's Statistics panel + * shows), or null when no UI is registered for its class, it shows nothing, or it fails. + * Matched by exact class, as Gephi's Statistics panel matches them. + */ + static String panelResult(Statistics stat, java.util.Collection uis) { + for (StatisticsUI ui : uis) { + if (!stat.getClass().equals(ui.getStatisticsClass())) { + continue; + } + try { + String shown = ui.getValue(stat); + return shown == null || shown.isBlank() ? null : shown.trim(); + } catch (RuntimeException e) { + LOGGER.log(Level.FINE, "Statistic UI gave no result for " + stat.getClass().getName(), e); + return null; + } + } + return null; + } + + private void tryAddResult(JsonObject r, Object obj, String getter, String jsonKey) { + try { + java.lang.reflect.Method m = obj.getClass().getMethod(getter); + Object val = m.invoke(obj); + if (val instanceof Number) { + r.addProperty(jsonKey, (Number) val); + } else if (val instanceof Boolean) { + r.addProperty(jsonKey, (Boolean) val); + } else if (val != null) { + r.addProperty(jsonKey, val.toString()); + } + } catch (NoSuchMethodException e) { /* method not available for this statistic */ + } catch (Exception e) { + LOGGER.fine("Could not get " + getter + ": " + e.getMessage()); + } + } + + public JsonObject computeModularity(double resolution) { + return computeModularity(resolution, 0); + } + + public JsonObject computeModularity(double resolution, long timeoutMs) { + java.util.Map params = new java.util.HashMap<>(); + params.put("resolution", resolution); + if (timeoutMs > 0) { + params.put("timeout_ms", timeoutMs); + } + params.put("useWeight", false); + return runStatistic("Modularity", params); + } + + public JsonObject computeDegree() { + return runStatistic("Degree", null); + } + + public JsonObject computeBetweenness() { + return runStatistic("GraphDistance", null); + } + + public JsonObject computePageRank() { + return runStatistic("PageRank", null); + } + + public JsonObject computeConnectedComponents() { + return runStatistic("ConnectedComponents", null); + } + + public JsonObject computeClusteringCoefficient() { + return runStatistic("ClusteringCoefficient", null); + } + + public JsonObject computeAvgPathLength() { + java.util.Map params = new java.util.HashMap<>(); + params.put("directed", false); + return runStatistic("GraphDistance", params); + } + + public JsonObject computeHITS() { + return runStatistic("HITS", null); + } + + public JsonObject computeEigenvectorCentrality() { + return runStatistic("EigenvectorCentrality", null); + } + + // ─── Filters ───────────────────────────────────────────────────── + + /** Nodes whose degree falls outside [min, max]; a max of 0 or less means no upper bound. */ + static java.util.List nodesOutsideDegreeRange(Graph g, int minDegree, int maxDegree) { + java.util.List out = new java.util.ArrayList<>(); + for (Node n : g.getNodes().toArray()) { + int deg = g.getDegree(n); + if (deg < minDegree || (maxDegree > 0 && deg > maxDegree)) { + out.add(n); + } + } + return out; + } + + /** + * Removes the nodes outside the degree range. They are chosen after the write lock is taken, + * so another write landing first cannot leave the list describing a graph that has changed. + */ + static int removeNodesOutsideDegreeRange(Graph g, int minDegree, int maxDegree) { + lockWrite(g); + try { + java.util.List toRemove = nodesOutsideDegreeRange(g, minDegree, maxDegree); + for (Node n : toRemove) { + g.removeNode(n); + } + return toRemove.size(); + } finally { + unlockWrite(g); + } + } + + /** Edges whose weight falls outside [min, max]; a max of 0 or less means no upper bound. */ + static java.util.List edgesOutsideWeightRange(Graph g, double minWeight, double maxWeight) { + java.util.List out = new java.util.ArrayList<>(); + for (Edge e : g.getEdges().toArray()) { + double w = e.getWeight(); + if (w < minWeight || (maxWeight > 0 && w > maxWeight)) { + out.add(e); + } + } + return out; + } + + /** Removes the edges outside the weight range, chosen under the write lock. */ + static int removeEdgesOutsideWeightRange(Graph g, double minWeight, double maxWeight) { + lockWrite(g); + try { + java.util.List toRemove = edgesOutsideWeightRange(g, minWeight, maxWeight); + for (Edge e : toRemove) { + g.removeEdge(e); + } + return toRemove.size(); + } finally { + unlockWrite(g); + } + } + + public JsonObject filterByDegreeRange(int minDegree, int maxDegree, boolean dryRun) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph g = currentGraphModel().getGraph(); + if (dryRun) { + java.util.List toRemove = nodesOutsideDegreeRange(g, minDegree, maxDegree); + JsonObject r = success("Dry run: " + toRemove.size() + " nodes would be removed"); + r.addProperty("would_remove", toRemove.size()); + r.addProperty("would_remain", g.getNodeCount() - toRemove.size()); + r.addProperty("dry_run", true); + return r; + } + int removed = removeNodesOutsideDegreeRange(g, minDegree, maxDegree); + JsonObject r = success("Filtered by degree [" + minDegree + ", " + maxDegree + "]"); + r.addProperty("removed", removed); + r.addProperty("remaining_nodes", g.getNodeCount()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject filterByEdgeWeight(double minWeight, double maxWeight, boolean dryRun) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph g = currentGraphModel().getGraph(); + if (dryRun) { + java.util.List toRemove = edgesOutsideWeightRange(g, minWeight, maxWeight); + JsonObject r = success("Dry run: " + toRemove.size() + " edges would be removed"); + r.addProperty("would_remove", toRemove.size()); + r.addProperty("would_remain", g.getEdgeCount() - toRemove.size()); + r.addProperty("dry_run", true); + return r; + } + int removed = removeEdgesOutsideWeightRange(g, minWeight, maxWeight); + JsonObject r = success("Filtered edges by weight [" + minWeight + ", " + maxWeight + "]"); + r.addProperty("removed", removed); + r.addProperty("remaining_edges", g.getEdgeCount()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Preview Settings ──────────────────────────────────────────── + + public JsonObject getPreviewSettings() { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc.getModel(ws); + if (pm == null) { + return error("Preview model not available"); + } + + JsonObject settings = new JsonObject(); + // Get commonly used properties + for (PreviewProperty prop : pm.getProperties().getProperties()) { + String name = prop.getName(); + Object val = prop.getValue(); + if (val != null) { + if (val instanceof Color) { + Color c = (Color) val; + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), + c.getBlue())); + } else if (val instanceof Number) { + settings.addProperty(name, (Number) val); + } else if (val instanceof Boolean) { + settings.addProperty(name, (Boolean) val); + } else if (val instanceof java.awt.Font) { + java.awt.Font f = (java.awt.Font) val; + String style = f.isBold() && f.isItalic() ? "BoldItalic" : f.isBold() ? "Bold" + : f.isItalic() ? "Italic" : "Plain"; + settings.addProperty(name, f.getFamily() + " " + f.getSize() + " " + style); + } else if (val instanceof EdgeColor) { + EdgeColor ec = (EdgeColor) val; + if (ec.getMode() == EdgeColor.Mode.ORIGINAL) { + settings.addProperty(name, "original"); + } else if (ec.getMode() == EdgeColor.Mode.MIXED) { + settings.addProperty(name, "mixed"); + } else if (ec.getCustomColor() != null) { + Color c = ec.getCustomColor(); + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), + c.getBlue())); + } else { + settings.addProperty(name, ec.getMode().toString().toLowerCase()); + } + } else if (val instanceof DependantColor) { + DependantColor dc = (DependantColor) val; + if (dc.getMode() == DependantColor.Mode.PARENT) { + settings.addProperty(name, "parent"); + } else if (dc.getMode() == DependantColor.Mode.DARKER) { + settings.addProperty(name, "darker"); + } else if (dc.getCustomColor() != null) { + Color c = dc.getCustomColor(); + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), + c.getBlue())); + } else { + settings.addProperty(name, "parent"); + } + } else if (val instanceof DependantOriginalColor) { + DependantOriginalColor doc = (DependantOriginalColor) val; + if (doc.getMode() == DependantOriginalColor.Mode.ORIGINAL) { + settings.addProperty(name, "original"); + } else if (doc.getMode() == DependantOriginalColor.Mode.PARENT) { + settings.addProperty(name, "parent"); + } else if (doc.getCustomColor() != null) { + Color c = doc.getCustomColor(); + settings.addProperty(name, String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), + c.getBlue())); + } else { + settings.addProperty(name, "original"); + } + } else { + settings.addProperty(name, val.toString()); + } + } + } + + // Include background color if not already captured by the main loop + try { + Object bgVal = pm.getProperties().getValue("background.color"); + if (bgVal instanceof Color) { + Color c = (Color) bgVal; + settings.addProperty("background.color", String.format("#%02x%02x%02x", c.getRed(), + c.getGreen(), c.getBlue())); + } + } catch (Exception ignored) { + // No background colour in these settings: it is left out. + } + + JsonObject r = new JsonObject(); + r.addProperty("success", true); + r.add("settings", settings); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setPreviewSettings(Map settings) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc.getModel(ws); + if (pm == null) { + return error("Preview model not available"); + } + + int set = 0; + for (Map.Entry e : settings.entrySet()) { + String key = e.getKey(); + Object val = e.getValue(); + if (val == null) { + continue; // Skip null values to avoid corrupting preview model + + // Background color: set on the preview model under Gephi's canonical key + // (PreviewProperty.BACKGROUND_COLOR) so the Preview panel, the renderers, + // and exportPng's export-time read all share one source of truth. The old + // cached exportBackgroundColor field was process-wide sticky state: once + // set it tinted every later export in every workspace and project, even + // after the user changed the background in Gephi's own Preview panel. + } + if ("background.color".equalsIgnoreCase(key) || "backgroundColor".equalsIgnoreCase(key)) { + try { + String hex = val.toString().trim(); + if (hex.startsWith("#")) { + hex = hex.substring(1); + } + Color bgColor = new Color(Integer.parseInt(hex, 16)); + PreviewProperty bgProp = pm.getProperties().getProperty(PreviewProperty.BACKGROUND_COLOR); + if (bgProp != null) { + bgProp.setValue(bgColor); + } else { + pm.getProperties().putValue(PreviewProperty.BACKGROUND_COLOR, bgColor); + } + set++; + } catch (NumberFormatException nfe) { + LOGGER.warning("MCP: Invalid background color: " + val); + } + continue; + } + + PreviewProperty prop = pm.getProperties().getProperty(key); + if (prop == null) { + // Property registry may not be initialized in this workspace + // (e.g. Preview never opened). putValue works regardless and + // renderers read it at export time. Non-scalar values are + // never valid preview properties — storing one corrupts the + // model, so skip them. + if (val instanceof Map || val instanceof List) { + LOGGER.warning("MCP: Skipping non-scalar preview value for " + key); + continue; + } + Object coerced = val; + if (val instanceof String) { + String sv = ((String) val).trim(); + if (sv.equalsIgnoreCase("true") || sv.equalsIgnoreCase("false")) { + coerced = Boolean.parseBoolean(sv); + } else { + try { + coerced = Float.parseFloat(sv); + } catch (NumberFormatException ignore) { + // Not a number: the text is kept as given. + } + } + } else if (val instanceof Number) { + coerced = ((Number) val).floatValue(); + } else if (val instanceof Boolean) { + coerced = val; + } + pm.getProperties().putValue(key, coerced); + set++; + continue; + } + if (prop != null) { + // Convert value based on property type + Class type = prop.getType(); + try { + if (type == Color.class && val instanceof String) { + String hex = (String) val; + if (hex.startsWith("#")) { + hex = hex.substring(1); + } + prop.setValue(new Color(Integer.parseInt(hex, 16))); + } else if (type == Boolean.class || type == boolean.class) { + prop.setValue(Boolean.parseBoolean(val.toString())); + } else if (type == Float.class || type == float.class) { + prop.setValue(Float.parseFloat(val.toString())); + } else if (type == Integer.class || type == int.class) { + prop.setValue(Integer.parseInt(val.toString())); + } else if (type == java.awt.Font.class && val instanceof String) { + // Parse font string like "Courier New 12 Bold" -> Font object + // Everything before first digit = name, first number = size, rest = style + String fontStr = val.toString().trim(); + String name = "Arial"; + int fontSize = 12; + int fontStyle = java.awt.Font.PLAIN; + int numStart = -1; + for (int ci = 0; ci < fontStr.length(); ci++) { + if (Character.isDigit(fontStr.charAt(ci))) { + numStart = ci; + break; + } + } + if (numStart > 0) { + name = fontStr.substring(0, numStart).trim(); + String[] rest = fontStr.substring(numStart).trim().split("\\s+"); + try { + fontSize = Integer.parseInt(rest[0]); + } catch (NumberFormatException ignored) { + // Not a number: the default font size stays. + } + for (int pi = 1; pi < rest.length; pi++) { + if ("Bold".equalsIgnoreCase(rest[pi])) { + fontStyle |= java.awt.Font.BOLD; + } else if ("Italic".equalsIgnoreCase(rest[pi])) { + fontStyle |= java.awt.Font.ITALIC; + } + } + } else if (numStart < 0) { + name = fontStr; + } + prop.setValue(new java.awt.Font(name, fontStyle, fontSize)); + } else if (type == java.awt.Font.class) { + continue; // Non-string font value, skip + } else if (type == DependantColor.class && val instanceof String) { + String s = val.toString().trim().toLowerCase(); + if ("parent".equals(s)) { + prop.setValue(new DependantColor(DependantColor.Mode.PARENT)); + } else if ("darker".equals(s)) { + prop.setValue(new DependantColor(DependantColor.Mode.DARKER)); + } else if (s.startsWith("#")) { + prop.setValue(new DependantColor(new Color(Integer.parseInt(s.substring(1), 16)))); + } else { + continue; + } + } else if (type == DependantOriginalColor.class && val instanceof String) { + String s = val.toString().trim().toLowerCase(); + if ("parent".equals(s)) { + prop.setValue(new DependantOriginalColor(DependantOriginalColor.Mode.PARENT)); + } else if ("original".equals(s)) { + prop.setValue(new DependantOriginalColor(DependantOriginalColor.Mode.ORIGINAL)); + } else if (s.startsWith("#")) { + prop.setValue( + new DependantOriginalColor(new Color(Integer.parseInt(s.substring(1), 16)))); + } else { + continue; + } + } else if (type == EdgeColor.class && val instanceof String) { + // For "source"/"target": color edges individually instead of using + // EdgeColor mode (which corrupts SVG rendering in Gephi 0.10) + String s = val.toString().trim().toLowerCase(); + if ("source".equals(s) || "target".equals(s)) { + boolean useSource = "source".equals(s); + Graph graph = currentGraphModel().getGraph(); + Node[] graphNodes = graph.getNodes().toArray(); + Edge[] graphEdges = graph.getEdges().toArray(); + java.util.Map nodeColors = new java.util.HashMap<>(); + for (Node n : graphNodes) { + nodeColors.put(n, n.getColor()); + } + for (Edge edge : graphEdges) { + Node ref = useSource ? edge.getSource() : edge.getTarget(); + Color c = nodeColors.get(ref); + if (c != null) { + edge.setColor(c); + } + } + prop.setValue(new EdgeColor(EdgeColor.Mode.ORIGINAL)); + } else if ("mixed".equals(s)) { + prop.setValue(new EdgeColor(EdgeColor.Mode.MIXED)); + } else if ("original".equals(s)) { + prop.setValue(new EdgeColor(EdgeColor.Mode.ORIGINAL)); + } else if (s.startsWith("#")) { + prop.setValue(new EdgeColor(new Color(Integer.parseInt(s.substring(1), 16)))); + } else { + continue; + } + } else { + continue; // Skip unknown types + } + set++; + } catch (NumberFormatException nfe) { + LOGGER.warning("MCP: Invalid number/color value for " + key + ": " + val); + continue; + } catch (Exception ex) { + LOGGER.warning("MCP: Failed to set preview property " + key + ": " + ex.getMessage()); + continue; + } + } + } + JsonObject r = success("Set " + set + " preview properties"); + r.addProperty("properties_set", set); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Export ─────────────────────────────────────────────────────── + + public JsonObject exportGexf(String filePath) { + return exportGexf(filePath, true); + } + + /** + * @param visible export the filtered visible graph (true — the historical behaviour) + * or the full graph. Either way the response self-declares which view was + * written via addViewInfo, so a filtered export is never silent about it. + */ + public JsonObject exportGexf(String filePath, boolean visible) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("gexf"); + if (exporter == null) { + return error("GEXF exporter not available"); + } + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(visible); + ((GraphExporter) exporter).setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + JsonObject r = success("Exported to " + filePath); + addViewInfo(r, currentGraphModel(), visible); + return r; + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + /** GEXF export returned inline as a string — no file round-trip. */ + public JsonObject exportGexfContent() { + return exportGexfContent(true); + } + + /** + * @param visible export the filtered visible graph (true — the historical behaviour) + * or the full graph. Several downstream tools parse this inline GEXF as their + * read path, so the response self-declares the view via addViewInfo: with a + * filter active they would otherwise silently compute over a subgraph the + * read endpoints never described. + */ + public JsonObject exportGexfContent(boolean visible) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("gexf"); + if (exporter == null) { + return error("GEXF exporter not available"); + } + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(visible); + ((GraphExporter) exporter).setWorkspace(ws); + } + java.io.StringWriter sw = new java.io.StringWriter(); + ec.exportWriter(sw, (org.gephi.io.exporter.spi.CharacterExporter) exporter); + JsonObject r = success("GEXF exported inline"); + addViewInfo(r, currentGraphModel(), visible); + r.addProperty("content", sw.toString()); + return r; + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + public JsonObject exportPng(String filePath, int w, int h) { + // Runs on the calling thread: rendering the export and compositing the + // background below (ImageIO.read, a full BufferedImage copy, ImageIO.write — + // at a default 1920x1080) needs nothing from the EDT. No explicit preview + // refresh here either — PNGExporter.execute() calls PreviewController.refreshPreview() + // itself before rendering, so doing it again here would just rebuild it twice. + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("png"); + if (exporter == null) { + return error("PNG exporter not available"); + } + if (!(exporter instanceof PNGExporter)) { + return error("Unexpected PNG exporter implementation: " + exporter.getClass().getName()); + } + PNGExporter pngExporter = (PNGExporter) exporter; + pngExporter.setWidth(w); + pngExporter.setHeight(h); + + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setWorkspace(ws); + } + + ec.exportFile(new File(filePath), exporter); + + // Post-process: composite onto the preview model's background color. + // Gephi's PNG exporter renders a transparent background; this fills it. + // The color is read from the preview model AT EXPORT TIME, so a change in + // Gephi's own Preview panel (or another workspace's settings) is honoured + // rather than overridden by a stale process-wide copy. + Color bgColor = previewBackgroundColor(ws); + if (bgColor != null && !bgColor.equals(Color.WHITE)) { + BufferedImage exported = ImageIO.read(new File(filePath)); + if (exported != null) { + BufferedImage result = new BufferedImage(exported.getWidth(), exported.getHeight(), + BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = result.createGraphics(); + g2d.setColor(bgColor); + g2d.fillRect(0, 0, result.getWidth(), result.getHeight()); + g2d.drawImage(exported, 0, 0, null); + g2d.dispose(); + ImageIO.write(result, "PNG", new File(filePath)); + } + } + + return success("Exported to " + filePath); + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + /** The workspace's preview background color, or null when none is available. */ + private static Color previewBackgroundColor(Workspace ws) { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc != null ? pc.getModel(ws) : null; + if (pm == null) { + return null; + } + Object bg = pm.getProperties().getValue(PreviewProperty.BACKGROUND_COLOR); + // Legacy spelling: earlier plugin builds stored the color under "background.color". + if (!(bg instanceof Color)) { + bg = pm.getProperties().getValue("background.color"); + } + return bg instanceof Color ? (Color) bg : null; + } + + /** + * Export the LIVE Overview canvas as it is actually rendered on screen — selection + * highlighting, hover state, current camera framing — using Gephi's own built-in + * screenshot feature (org.gephi.visualization.api.ScreenshotController), the same + * backend behind the toolbar "take a snapshot" button. This is a DIFFERENT pipeline + * from exportPng: exportPng renders the graph's stored data (colors, positions) through + * the Preview renderer, which has no concept of selection at all. This method captures + * the actual GL framebuffer, so a person's box-drag selection (dimmed unselected nodes, + * vivid selected ones) shows up exactly as they see it. + * + * scaleFactor: a multiplier on the current on-screen canvas size (not literal pixel + * width/height like exportPng — Gephi's screenshot API only supports a scale factor). + * + * takeScreenshot() is asynchronous (queued against the render engine's next frame via + * a LongTaskExecutor), so this polls a dedicated fresh temp directory for the resulting + * file rather than assuming completion on return. + */ + public JsonObject exportScreenshot(String filePath, int scaleFactor, boolean transparentBackground) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + + File targetFile = new File(filePath).getAbsoluteFile(); + File targetDir = targetFile.getParentFile(); + if (targetDir != null) { + targetDir.mkdirs(); + } + + // ScreenshotController is not independently registered in Lookup — it is only + // reachable via VisualizationController.getScreenshotController() (the same + // VisualizationController singleton getSelection/focusView already use). + org.gephi.visualization.api.VisualizationController vc = Lookup.getDefault() + .lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) { + return error("Visualization controller not available"); + } + org.gephi.visualization.api.ScreenshotController sc = vc.getScreenshotController(); + if (sc == null) { + return error("Screenshot controller not available"); + } + + // This takeScreenshot writes straight to the file, whatever the toolbar's screenshot + // settings, never opens a dialog, and completes once the PNG is written. It is called + // from the HTTP thread: the Future must not be waited on from the interface thread. + // Gephi's first capture after it starts can fail (its image buffer is not ready yet), + // so a failed capture is tried once more. + String problem = null; + for (int attempt = 0; attempt < 2; attempt++) { + java.util.concurrent.Future future = + sc.takeScreenshot(scaleFactor, transparentBackground, targetFile); + try { + future.get(15, java.util.concurrent.TimeUnit.SECONDS); + JsonObject r = success("Exported to " + filePath); + r.addProperty("scale_factor", scaleFactor); + r.addProperty("selection_aware", true); + return r; + } catch (java.util.concurrent.TimeoutException e) { + future.cancel(true); + return error("Screenshot did not complete within 15s — the render engine may be busy, " + + "retry, or fully restart Gephi if this persists"); + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + problem = String.valueOf(cause.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return error("Interrupted while waiting for the screenshot"); + } + } + return error("Screenshot export failed: " + problem); + } + + /** + * Poll the live engine selection until its size matches expected or timeoutMs + * elapses. selectNodes()/resetSelection() queue their effect onto the render + * engine rather than applying it synchronously with the call, so a read (or a + * screenshot) taken immediately after can race ahead of it and see stale state. + * Returns the final observed size (may differ from expected on timeout). + */ + private static int waitForSelectionCount( + org.gephi.visualization.api.VisualizationController vc, int expected, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + int last = -1; + while (System.currentTimeMillis() < deadline) { + org.gephi.visualization.api.VisualizationModel model = vc.getModel(); + last = model != null ? model.getSelectedNodes().size() : 0; + if (last == expected) { + return last; + } + try { + Thread.sleep(30); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return last; + } + } + return last; + } + + /** + * True when the visible drawing is wider than it is tall, so it prints landscape. PDFs are + * always US Letter; only the orientation follows the layout. + */ + static boolean landscapeFor(Graph g) { + float minX = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + lockRead(g); + try { + for (Node n : g.getNodes().toArray()) { + minX = Math.min(minX, n.x()); + maxX = Math.max(maxX, n.x()); + minY = Math.min(minY, n.y()); + maxY = Math.max(maxY, n.y()); + } + } finally { + g.readUnlock(); + } + return maxX - minX > maxY - minY; + } + + public JsonObject exportPdf(String filePath) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph g = currentGraphModel().getGraph(); + if (g.getNodeCount() == 0) { + return error("Cannot export PDF: graph has no nodes"); + } + // No explicit preview refresh here — PDFExporter.execute() calls + // PreviewController.refreshPreview() itself before rendering. + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("pdf"); + if (exporter == null) { + return error("PDF exporter not available"); + } + if (!(exporter instanceof PDFExporter)) { + return error("Unexpected PDF exporter implementation: " + exporter.getClass().getName()); + } + boolean landscape = landscapeFor(currentGraphModel().getGraphVisible()); + PDFExporter pdf = (PDFExporter) exporter; + pdf.setPageSize(PDRectangle.LETTER); + pdf.setLandscape(landscape); + // Half-inch margins (in points), which any printer can reach. + pdf.setMarginTop(36f); + pdf.setMarginBottom(36f); + pdf.setMarginLeft(36f); + pdf.setMarginRight(36f); + if (exporter instanceof GraphExporter) { + exporter.setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + JsonObject r = success("Exported to " + filePath); + r.addProperty("page", landscape ? "US Letter, landscape (11 x 8.5 in)" + : "US Letter, portrait (8.5 x 11 in)"); + return r; + } catch (IllegalArgumentException e) { + return error("Export failed: graph nodes may not be positioned — run a layout first"); + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + public JsonObject exportSvg(String filePath) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + // No explicit preview refresh here — SVGExporter.execute() calls + // PreviewController.refreshPreview() itself before rendering. + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("svg"); + if (exporter == null) { + return error("SVG exporter not available"); + } + if (exporter instanceof GraphExporter) { + exporter.setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + return success("Exported to " + filePath); + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + public JsonObject exportGraphml(String filePath) { + return exportGraphml(filePath, true); + } + + /** + * @param visible see exportGexf — same contract, response self-declares the view. + */ + public JsonObject exportGraphml(String filePath, boolean visible) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter("graphml"); + if (exporter == null) { + return error("GraphML exporter not available"); + } + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(visible); + exporter.setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + JsonObject r = success("Exported to " + filePath); + addViewInfo(r, currentGraphModel(), visible); + return r; + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + public JsonObject exportCsv(String filePath, String separator, String target) { + // Runs on the calling thread: serialising the whole graph into a StringBuilder + // and writing it to disk is bulk work with no Swing dependency — it has no + // business on the EDT (see the threading note above setEdgeColor). + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + // Always use manual export — Gephi's built-in CSV exporter produces an adjacency matrix + return exportCsvManual(filePath, separator, target); + } + + private JsonObject exportCsvManual(String filePath, String separator, String target) { + try { + GraphModel gm = currentGraphModel(); + String csvText = buildCsv(gm, separator, target); + try (java.io.Writer fw = new java.io.OutputStreamWriter( + new java.io.FileOutputStream(filePath), java.nio.charset.StandardCharsets.UTF_8)) { + fw.write(csvText); + } + JsonObject r = success("Exported to " + filePath); + // CSV is built from the FULL graph (buildCsv walks gm.getGraph()) — declare + // that, since the other exporters write the visible graph. + addViewInfo(r, gm, false); + return r; + } catch (Exception e) { + return failure("CSV export failed: ", e); + } + } + + /** Build node/edge CSV text from a model (RFC 4180 quoted). Package-private + static for unit testing. */ + static String buildCsv(GraphModel gm, String separator, String target) { + Graph g = gm.getGraph(); + String sep = separator != null ? separator : ","; + StringBuilder sb = new StringBuilder(); + { + if (!"edges".equalsIgnoreCase(target)) { + // Export nodes + sb.append(csv("Id", sep)).append(sep).append(csv("Label", sep)); + for (Column col : gm.getNodeTable()) { + if (!col.isProperty()) { + sb.append(sep).append(csv(col.getTitle(), sep)); + } + } + sb.append("\n"); + lockRead(g); + try { + for (Node n : g.getNodes().toArray()) { + sb.append(csv(String.valueOf(n.getId()), sep)).append(sep) + .append(csv(n.getLabel() != null ? n.getLabel() : "", sep)); + for (Column col : gm.getNodeTable()) { + if (!col.isProperty()) { + Object v = n.getAttribute(col); + sb.append(sep).append(csv(v != null ? v.toString() : "", sep)); + } + } + sb.append("\n"); + } + } finally { + g.readUnlock(); + } + } + + if ("edges".equalsIgnoreCase(target) || "both".equalsIgnoreCase(target)) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(csv("Source", sep)).append(sep).append(csv("Target", sep)).append(sep) + .append(csv("Weight", sep)); + for (Column col : gm.getEdgeTable()) { + if (!col.isProperty()) { + sb.append(sep).append(csv(col.getTitle(), sep)); + } + } + sb.append("\n"); + lockRead(g); + try { + for (Edge e : g.getEdges().toArray()) { + sb.append(csv(String.valueOf(e.getSource().getId()), sep)).append(sep) + .append(csv(String.valueOf(e.getTarget().getId()), sep)).append(sep) + .append(csv(String.valueOf(e.getWeight()), sep)); + for (Column col : gm.getEdgeTable()) { + if (!col.isProperty()) { + Object v = e.getAttribute(col); + sb.append(sep).append(csv(v != null ? v.toString() : "", sep)); + } + } + sb.append("\n"); + } + } finally { + g.readUnlock(); + } + } + } + return sb.toString(); + } + + /** + * RFC 4180 field quoting: wrap the value in double quotes (doubling any internal + * quote) when it contains the separator, a quote, or a line break. Without this, + * a label or attribute containing the separator silently corrupts the columns. + */ + static String csv(String value, String sep) { + if (value == null) { + value = ""; + } + boolean needsQuote = value.contains(sep) || value.contains("\"") + || value.contains("\n") || value.contains("\r"); + return needsQuote ? "\"" + value.replace("\"", "\"\"") + "\"" : value; + } + + // ─── Import ────────────────────────────────────────────────────── + + public JsonObject importFile(String filePath) { + return importFile(filePath, null); + } + + public JsonObject importFile(String filePath, Float maxNodeSize) { + return importFile(filePath, maxNodeSize, null); + } + + /** + * Imports a file. By default ({@code mode} null or "new_workspace") the file gets its own + * workspace, as in Gephi's own import dialog: a workspace's graph settings (time format, id + * type, weight type) are fixed when it is created, so a new one is made to match the file. + * An empty workspace that was open is then removed rather than left behind. "append" adds + * the file to the current workspace instead, and fails with Gephi's report when the settings + * do not match. The file's import warnings are returned as {@code import_issues}. + * {@code maxNodeSize} caps imported node sizes when set; when null the file's own sizes are + * preserved exactly, so an import followed by an export round-trips. + */ + public JsonObject importFile(String filePath, Float maxNodeSize, String mode) { + // Runs on the calling thread. Gephi's own import runs off the event dispatch thread, + // and parsing a large file inside runOnEDT froze the UI and then blew its 15-second + // budget, so the caller was told "Gephi's UI thread is unresponsive, fully quit and + // reopen" while the import was in fact still running and went on to succeed. + { + File file = new File(filePath); + if (!file.exists()) { + return error("File not found: " + filePath); + } + try { + ImportController ic = Lookup.getDefault().lookup(ImportController.class); + Container c = ic.importFile(file); + if (c == null) { + return error("Import failed - unsupported format or empty file"); + } + + boolean append = "append".equalsIgnoreCase(mode); + if (mode != null && !append && !"new_workspace".equalsIgnoreCase(mode) + && !"new".equalsIgnoreCase(mode)) { + return error("Unknown import mode '" + mode + "': use \"new_workspace\" (the default)" + + " or \"append\""); + } + ProjectController pc = getProjectController(); + if (pc.getCurrentProject() == null) { + onProjectThread(() -> { + pc.newProject(); + return null; + }); + } + Workspace previous = currentWorkspace(); + boolean previousEmpty = false; + if (previous != null) { + Graph pg = getGraphController().getGraphModel(previous).getGraph(); + previousEmpty = pg.getNodeCount() == 0 && pg.getEdgeCount() == 0; + } + if (append && previous == null) { + return error("No workspace to append to"); + } + + Processor processor = findProcessor(append ? "AppendProcessor" : "DefaultProcessor"); + if (processor == null) { + processor = Lookup.getDefault().lookup(Processor.class); + } + if (processor == null) { + return error("No processor found"); + } + + // Gephi's import containers auto-scale by default: before processing, its + // DefaultScaler recenters every node on the centroid, rescales sizes into + // 4 to 100, and scales positions by the same ratio (clamped to +/-5000). + // That would rewrite the viz:position and viz:size values the file carries, + // so an export followed by an import would not round-trip. With auto-scale + // off, files without positions still get spread out at random when the + // container closes, and their sizes are kept as written. + c.getLoader().setAutoScale(false); + + JsonArray issues = new JsonArray(); + addIssues(issues, c.getReport(), 20); + Workspace importedWs; + try { + // A null workspace lets the processor create one whose settings match the file. + importedWs = ic.process(c, processor, append ? previous : null); + } catch (Exception e) { + JsonObject err = error("Import failed: " + e.getMessage() + (append + ? ". The file's time format or id type differs from this workspace's; import it" + + " without mode \"append\" to open it in its own workspace." : "")); + addIssues(issues, processor.getReport(), 20); + if (issues.size() > 0) { + err.add("import_issues", issues); + } + return err; + } + addIssues(issues, processor.getReport(), 20); + Workspace ws = importedWs != null ? importedWs : previous; + // Named after the file, as Gephi names a workspace opened from File > Open. + final boolean tidy = !append && previousEmpty && previous != null && previous != ws; + if (!append && ws != null && ws != previous) { + onProjectThread(() -> { + pc.renameWorkspace(ws, file.getName()); + if (tidy) { + // The import already made its workspace current; switching to it + // again would close and reopen it for nothing. + if (pc.getCurrentWorkspace() != ws) { + pc.openWorkspace(ws); + } + pc.deleteWorkspace(previous); + } + return null; + }); + } + + // Optional, and off by default. Capping rewrites viz:size values the file + // actually carries, so importing and re-exporting would silently change the + // user's data. It stays available because oversized nodes from GEXF can hide + // the whole graph, but only when the caller asks for it. + int capped = 0; + if (maxNodeSize != null && maxNodeSize > 0) { + Graph importedGraph = getGraphController().getGraphModel(ws).getGraph(); + lockWrite(importedGraph); + try { + for (Node n : importedGraph.getNodes().toArray()) { + if (n.size() > maxNodeSize) { + n.setSize(maxNodeSize.floatValue()); + capped++; + } + } + } finally { + unlockWrite(importedGraph); + } + } + + Workspace effectiveWs = importedWs != null ? importedWs : ws; + final Graph g = getGraphController().getGraphModel(effectiveWs).getGraph(); + JsonObject r = success("Imported from " + file.getName()); + r.addProperty("import_mode", append ? "append" : "new_workspace"); + if (issues.size() > 0) { + r.add("import_issues", issues); + } + if (capped > 0) { + r.addProperty("nodes_size_capped", capped); + r.addProperty("max_node_size", maxNodeSize); + } + r.addProperty("node_count", g.getNodeCount()); + r.addProperty("edge_count", g.getEdgeCount()); + return r; + } catch (Exception e) { + return failure("Import failed: ", e); + } + } + } + + /** Collect up to {@code limit} import issues (level and message) from a report. */ + static void addIssues(JsonArray out, org.gephi.io.importer.api.Report report, int limit) { + if (report == null) { + return; + } + for (org.gephi.io.importer.api.Issue issue : report.getIssuesList(limit)) { + if (out.size() >= limit) { + return; + } + JsonObject o = new JsonObject(); + o.addProperty("level", String.valueOf(issue.getLevel())); + o.addProperty("message", issue.getMessage()); + out.add(o); + } + } + + private static Processor findProcessor(String simpleName) { + for (Processor p : Lookup.getDefault().lookupAll(Processor.class)) { + if (p.getClass().getSimpleName().equals(simpleName)) { + return p; + } + } + return null; + } + + // ─── Graph Operations ──────────────────────────────────────────── + + public JsonObject clearGraph() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + int nodeCount = g.getNodeCount(); + int edgeCount = g.getEdgeCount(); + g.clear(); + JsonObject r = success("Graph cleared"); + r.addProperty("nodes_removed", nodeCount); + r.addProperty("edges_removed", edgeCount); + return r; + } finally { + unlockWrite(g); + } + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** Nodes with no ties at all, which remove-isolates deletes. Reads only; call under a lock. */ + static java.util.List isolatedNodes(Graph g) { + java.util.List isolates = new java.util.ArrayList<>(); + for (Node n : g.getNodes().toArray()) { + if (g.getDegree(n) == 0) { + isolates.add(n); + } + } + return isolates; + } + + public JsonObject removeIsolates() { + return removeIsolates(false); + } + + /** Remove every node with no ties, or with {@code dryRun} only count them and change nothing. */ + public JsonObject removeIsolates(boolean dryRun) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph g = currentGraphModel().getGraph(); + if (dryRun) { + lockRead(g); + try { + int count = isolatedNodes(g).size(); + JsonObject r = success("Would remove " + count + " isolated nodes"); + r.addProperty("dry_run", true); + r.addProperty("would_remove", count); + r.addProperty("remaining_nodes", g.getNodeCount() - count); + return r; + } finally { + g.readUnlock(); + } + } + java.util.List isolates; + lockWrite(g); + try { + isolates = isolatedNodes(g); + for (Node n : isolates) { + g.removeNode(n); + } + } finally { + unlockWrite(g); + } + JsonObject r = success("Removed " + isolates.size() + " isolated nodes"); + r.addProperty("removed", isolates.size()); + r.addProperty("remaining_nodes", g.getNodeCount()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject extractEgoNetwork(String nodeId, int depth) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + Graph g = currentGraphModel().getGraph(); + Node center = g.getNode(nodeId); + if (center == null) { + return error("Node not found: " + nodeId); + } + + // BFS to find nodes within depth + java.util.Set keep = new java.util.LinkedHashSet<>(); + java.util.Queue queue = new java.util.LinkedList<>(); + java.util.Map distances = new java.util.HashMap<>(); + keep.add(center); + queue.add(center); + distances.put(center, 0); + + while (!queue.isEmpty()) { + Node current = queue.poll(); + int dist = distances.get(current); + if (dist >= depth) { + continue; + } + for (Node neighbor : g.getNeighbors(current).toArray()) { + if (!keep.contains(neighbor)) { + keep.add(neighbor); + queue.add(neighbor); + distances.put(neighbor, dist + 1); + } + } + } + + // Remove nodes not in keep set + java.util.List toRemove = new java.util.ArrayList<>(); + lockWrite(g); + try { + for (Node n : g.getNodes().toArray()) { + if (!keep.contains(n)) { + toRemove.add(n); + } + } + for (Node n : toRemove) { + g.removeNode(n); + } + } finally { + unlockWrite(g); + } + + JsonObject r = success("Ego network extracted for " + nodeId); + r.addProperty("kept_nodes", keep.size()); + r.addProperty("removed_nodes", toRemove.size()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject extractGiantComponent() { + // Statistics must run OFF the EDT (they dispatch UI work to EDT internally). + // Node removal runs on the calling thread too — it doesn't need it either. + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + final Graph g = gm.getGraph(); + + // Run connected components (on HTTP thread, not EDT) + StatisticsBuilder ccBuilder = null; + for (StatisticsBuilder sb : Lookup.getDefault().lookupAll(StatisticsBuilder.class)) { + if (sb.getName().equalsIgnoreCase("ConnectedComponents") || + sb.getClass().getSimpleName().toLowerCase().contains("connectedcomponents")) { + ccBuilder = sb; + break; + } + } + if (ccBuilder == null) { + return error("ConnectedComponents statistic not found"); + } + + Statistics stat = ccBuilder.getStatistics(); + stat.execute(gm); + + // Find the column + Column ccCol = gm.getNodeTable().getColumn("componentnumber"); + if (ccCol == null) { + // A copy: breaking out of a loop over the table itself would leave it locked. + for (Column col : gm.getNodeTable().toArray()) { + if (col.getTitle().toLowerCase().contains("component")) { + ccCol = col; + break; + } + } + } + if (ccCol == null) { + return error("Component column not found after running statistics"); + } + + // Count nodes per component + java.util.Map componentSizes = new java.util.HashMap<>(); + Node[] allNodes = g.getNodes().toArray(); + final Column fccCol = ccCol; + for (Node n : allNodes) { + Object v = n.getAttribute(fccCol); + int comp = v instanceof Number ? ((Number) v).intValue() : 0; + componentSizes.put(comp, componentSizes.getOrDefault(comp, 0) + 1); + } + + int giantComp = 0; + int giantSize = 0; + for (java.util.Map.Entry e : componentSizes.entrySet()) { + if (e.getValue() > giantSize) { + giantSize = e.getValue(); + giantComp = e.getKey(); + } + } + + // Remove nodes on the calling thread — graph mutation needs only the graph + // write lock (see the threading note above setEdgeColor). + java.util.List toRemove = new java.util.ArrayList<>(); + for (Node n : allNodes) { + Object v = n.getAttribute(fccCol); + int comp = v instanceof Number ? ((Number) v).intValue() : -1; + if (comp != giantComp) { + toRemove.add(n); + } + } + lockWrite(g); + try { + for (Node n : toRemove) { + g.removeNode(n); + } + } finally { + unlockWrite(g); + } + JsonObject r = success("Giant component extracted"); + r.addProperty("kept_nodes", giantSize); + r.addProperty("removed_nodes", toRemove.size()); + r.addProperty("component_count", componentSizes.size()); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject setEdgeThicknessByWeight(float minThickness, float maxThickness) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + PreviewController pc = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel pm = pc.getModel(ws); + if (pm == null) { + return error("Preview model not available"); + } + + // Set edge thickness to be rescaled based on weight + // Use the preview property for edge thickness + PreviewProperty edgeThicknessProp = pm.getProperties().getProperty("edge.thickness"); + if (edgeThicknessProp != null) { + edgeThicknessProp.setValue(minThickness); + } + + // Set rescale weight property if available + PreviewProperty rescaleProp = pm.getProperties().getProperty("edge.rescale-weight"); + if (rescaleProp != null) { + rescaleProp.setValue(true); + } + + PreviewProperty rescaleMinProp = pm.getProperties().getProperty("edge.rescale-weight.min"); + if (rescaleMinProp != null) { + rescaleMinProp.setValue(minThickness); + } + + PreviewProperty rescaleMaxProp = pm.getProperties().getProperty("edge.rescale-weight.max"); + if (rescaleMaxProp != null) { + rescaleMaxProp.setValue(maxThickness); + } + + JsonObject r = success("Edge thickness configured by weight"); + r.addProperty("min_thickness", minThickness); + r.addProperty("max_thickness", maxThickness); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + public JsonObject resetFilters() { + try { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + GraphModel gm = currentGraphModel(); + Graph g = gm.getGraph(); + // setVisibleView() takes Gephi's own blocking write lock; hold our deadlock-safe + // lock first so that call re-enters instead of queuing behind the renderer. + lockWrite(g); + try { + gm.setVisibleView(null); + } finally { + unlockWrite(g); + } + return success("Filters reset - full graph view restored"); + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + // ─── Shutdown ──────────────────────────────────────────────────── + + /** Layouts run in Gephi's own LayoutController, which outlives the server, so stopping it leaves them alone. */ + public void shutdown() { + } + + /** + * Cheap wedge detector for /health: try the graph read lock briefly. + * "ok" = acquired instantly; "busy" = could not acquire (a writer is parked or + * the renderer is saturating the lock — if persistent, Gephi needs a restart); + * "none" = no workspace open. + */ + public String graphLockProbe() { + try { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return "none"; + } + Graph g = gm.getGraph(); + java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock rl = readLockHandle(g); + if (rl == null) { + return "unknown"; + } + if (rl.tryLock(150, java.util.concurrent.TimeUnit.MILLISECONDS)) { + rl.unlock(); + return "ok"; + } + return "busy"; + } catch (Throwable t) { + return "unknown"; + } + } + + /** + * Live counters from the underlying ReentrantReadWriteLock: active read holds, + * write-locked flag, and queued threads. Diagnostic companion to graphLockProbe; + * a nonzero reader count while Gephi is idle means a leaked read hold (the + * precursor of a permanent wedge). All values -1 when unreachable. + */ + public JsonObject graphLockStats() { + JsonObject o = new JsonObject(); + o.addProperty("readers", -1); + o.addProperty("write_locked", false); + o.addProperty("queued", -1); + try { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return o; + } + org.gephi.graph.api.GraphLock lock = gm.getGraph().getLock(); + if (lock == null) { + return o; + } + java.lang.reflect.Field f = lock.getClass().getDeclaredField("readWriteLock"); + f.setAccessible(true); + Object v = f.get(lock); + if (v instanceof java.util.concurrent.locks.ReentrantReadWriteLock) { + java.util.concurrent.locks.ReentrantReadWriteLock rwl = + (java.util.concurrent.locks.ReentrantReadWriteLock) v; + o.addProperty("readers", rwl.getReadLockCount()); + o.addProperty("write_locked", rwl.isWriteLocked()); + o.addProperty("queued", rwl.getQueueLength()); + } + } catch (Throwable t) { + // leave the -1 defaults + } + return o; + } + + // ─── Human selection journal ───────────────────────────────────────── + + /** + * Install the passive NODE_LEFT_CLICK listener once. Safe to call often; + * no-ops until the visualization is available. The listener returns false + * (observe, never consume) so Gephi's own tools keep working. + */ + public synchronized void ensureClickListener() { + if (clickListenerInstalled) { + return; + } + org.gephi.visualization.api.VisualizationController vc = + Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) { + return; + } + vc.addListener(new org.gephi.visualization.api.VisualizationEventListener() { + @Override + public boolean handleEvent(org.gephi.visualization.api.VisualizationEvent event) { + try { + Object data = event.getData(); + if (data instanceof Node[]) { + Node[] nodes = (Node[]) data; + if (nodes.length > 0) { + recordClick(nodes); + } + } + } catch (Throwable t) { + // Never disturb the viz event thread. + } + return false; + } + + @Override + public org.gephi.visualization.api.VisualizationEvent.Type getType() { + return org.gephi.visualization.api.VisualizationEvent.Type.NODE_LEFT_CLICK; + } + }); + clickListenerInstalled = true; + // Deliberately does NOT enable rectangle selection. Installing the listener is + // passive observation and is safe to do at startup, which is where it happens so + // that clicks made before an assistant ever connects are still recorded. Changing + // the mouse mode is not passive: it would alter the tool every user of this plugin + // sees on every launch, including those who never connect an assistant. + // getSelection() enables rectangle selection instead, because a caller asking what + // is selected is the point at which the user is actually driving the assistant. + } + + /** + * Turn on rectangle (box-drag) selection once per session so the human can + * point at nodes for the agent to read, without first clicking the toolbar's + * selection tool. No-op if the view isn't started yet (retried on the next + * call) or if it is already on. Never overrides a mode the human later sets + * on their own — it fires at most once, and only while selection is still off. + */ + void ensureRectangleSelection() { + if (rectangleAutoEnabled) { + return; + } + try { + org.gephi.visualization.api.VisualizationController vc = Lookup.getDefault() + .lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) { + return; + } + org.gephi.visualization.api.VisualizationModel model = vc.getModel(); + if (model == null) { + return; // view not started; try again next call + } + if (!model.isRectangleSelection()) { + vc.setRectangleSelection(); + } + rectangleAutoEnabled = true; + } catch (Throwable t) { + // Never disturb a health/selection call over a viz hiccup. + } + } + + private void recordClick(Node[] nodes) { + JsonObject entry = new JsonObject(); + entry.addProperty("time_ms", System.currentTimeMillis()); + JsonArray arr = new JsonArray(); + for (Node n : nodes) { + JsonObject jn = new JsonObject(); + jn.addProperty("id", String.valueOf(n.getId())); + String label = n.getLabel(); + if (label != null && !label.isEmpty() && !label.equals(String.valueOf(n.getId()))) { + jn.addProperty("label", label); + } + arr.add(jn); + } + entry.add("nodes", arr); + synchronized (clickJournal) { + clickJournal.addLast(entry); + while (clickJournal.size() > CLICK_JOURNAL_MAX) { + clickJournal.removeFirst(); + } + } + } + + private static final int SELECTION_MAX_NODES = 200; + + private JsonObject nodeRef(Node n) { + JsonObject jn = new JsonObject(); + jn.addProperty("id", String.valueOf(n.getId())); + String label = n.getLabel(); + if (label != null && !label.isEmpty() && !label.equals(String.valueOf(n.getId()))) { + jn.addProperty("label", label); + } + return jn; + } + + /** + * What the human has selected in the Gephi window. Two sources: + * selected_now — the persistent selection (rectangle selection keeps it + * after the mouse moves away; the primary channel), read from + * VisualizationModel.getSelectedNodes(); clicks — the + * NODE_LEFT_CLICK journal (fires only in modes that populate the engine + * selection at click time). clear=true consumes the journal only; the + * live selection always reflects the canvas. + */ + public JsonObject getSelection(boolean clear) { + ensureClickListener(); + ensureRectangleSelection(); + JsonObject r = success("Human selection"); + JsonArray selected = new JsonArray(); + int totalSelected = 0; + try { + org.gephi.visualization.api.VisualizationController vc = Lookup.getDefault() + .lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc != null) { + // Report the canvas state so the agent can explain an empty selection + // (e.g. rectangle mode off) instead of silently returning nothing. + org.gephi.visualization.api.VisualizationModel model = vc.getModel(); + java.util.Collection sel = null; + if (model != null) { + r.addProperty("selection_enabled", model.isSelectionEnabled()); + r.addProperty("rectangle_selection", model.isRectangleSelection()); + r.addProperty("zoom", model.getZoom()); + // Public read path — no dependency on the internal viz engine. + sel = model.getSelectedNodes(); + } + if (sel != null) { + for (Node n : sel) { + totalSelected++; + if (selected.size() < SELECTION_MAX_NODES) { + selected.add(nodeRef(n)); + } + } + } + } + } catch (Throwable t) { + r.addProperty("selection_error", t.getClass().getSimpleName() + ": " + t.getMessage()); + } + r.add("selected_now", selected); + r.addProperty("selected_count", totalSelected); + if (totalSelected > SELECTION_MAX_NODES) { + r.addProperty("selected_truncated", true); + } + JsonArray clicks = new JsonArray(); + synchronized (clickJournal) { + for (JsonObject e : clickJournal) { + clicks.add(e.deepCopy()); + } + if (clear) { + clickJournal.clear(); + } + } + r.add("clicks", clicks); + r.addProperty("click_count", clicks.size()); + r.addProperty("listener_active", clickListenerInstalled); + return r; + } + + // ─── View / camera control (teaching mode) ────────────────────────── + + /** + * Direct the human viewer's attention in the Gephi window: center the camera on + * the graph, a node, an edge, or a region; optionally select nodes (visual + * highlight) and set zoom. No-op modes never touch the graph write lock. + */ + public JsonObject focusView(String mode, String nodeId, String source, String target, + Double x, Double y, Double w, Double h, + Double zoom, java.util.List select) { + org.gephi.visualization.api.VisualizationController vc = + Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) { + return error("No visualization available (headless or view not started)"); + } + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + Graph g = gm.getGraph(); + try { + String m = mode == null ? "graph" : mode.toLowerCase(); + switch (m) { + case "graph": + vc.centerOnGraph(); + break; + case "zero": + vc.centerOnZero(); + break; + case "node": { + if (nodeId == null) { + return error("Missing 'id' for mode=node"); + } + Node n = g.getNode(nodeId); + if (n == null) { + return error("Node not found: " + nodeId); + } + vc.centerOnNode(n); + break; + } + case "edge": { + if (source == null || target == null) { + return error("Missing 'source'/'target' for mode=edge"); + } + Node ns = g.getNode(source); + Node nt = g.getNode(target); + if (ns == null || nt == null) { + return error("Edge endpoints not found"); + } + Edge e = g.getEdge(ns, nt, 1); // directed + if (e == null) { + e = g.getEdge(ns, nt, 0); // undirected + } + if (e == null) { + e = g.getEdge(ns, nt); // default + } + if (e == null) { + e = g.getEdge(nt, ns, 1); + } + if (e == null) { + e = g.getEdge(nt, ns, 0); + } + if (e == null) { + e = g.getEdge(nt, ns); + } + if (e == null) { + return error("Edge not found: " + source + " -> " + target); + } + vc.centerOnEdge(e); + break; + } + case "region": { + if (x == null || y == null || w == null || h == null) { + return error("Missing x/y/w/h for mode=region"); + } + vc.centerOn(x.floatValue(), y.floatValue(), w.floatValue(), h.floatValue()); + break; + } + default: + return error("Unknown mode: " + mode + " (use graph|zero|node|edge|region)"); + } + Integer selectedCount = null; + if (select != null) { + int expected; + if (select.isEmpty()) { + vc.resetSelection(); + expected = 0; + } else { + java.util.List nodes = new java.util.ArrayList<>(); + for (String id : select) { + Node n = g.getNode(id); + if (n != null) { + nodes.add(n); + } + } + vc.selectNodes(nodes.toArray(new Node[0])); + expected = nodes.size(); + } + // selectNodes()/resetSelection() apply asynchronously against the render + // engine (queued, not synchronous with this call) — wait briefly for the + // change to actually land instead of blindly echoing the request size, so + // a caller (e.g. gephi_get_selection or a screenshot right after) sees it + // too. Also correct for IDs that didn't resolve to a real node. + selectedCount = waitForSelectionCount(vc, expected, 1000); + } + if (zoom != null) { + vc.setZoom(zoom.floatValue()); + } + JsonObject r = success("View focused (" + m + ")"); + r.addProperty("mode", m); + if (selectedCount != null) { + r.addProperty("selected", selectedCount); + } + return r; + } catch (Exception e) { + return failure("Focus failed: ", e); + } + } + + /** + * Set the mouse selection mode on the graph canvas. "rectangle" enables the + * box-drag selection the pointing feature (readSelection) reads, so a + * teaching session can turn it on up front instead of asking the human to + * click the toolbar icon. Uses the same VisualizationController focusView + * already drives. + */ + public JsonObject setSelectionMode(String mode) { + org.gephi.visualization.api.VisualizationController vc = + Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class); + if (vc == null) { + return error("No visualization available (headless or view not started)"); + } + String m = mode == null ? "rectangle" : mode.toLowerCase(); + try { + switch (m) { + case "rectangle": + vc.setRectangleSelection(); + break; + case "direct": + vc.setDirectMouseSelection(); + break; + case "disable": + case "off": + vc.disableSelection(); + break; + default: + return error("Unknown selection mode: " + mode + " (use rectangle|direct|disable)"); + } + JsonObject r = success("Selection mode set to " + m); + r.addProperty("mode", m); + return r; + } catch (Exception e) { + return failure("Set selection mode failed: ", e); + } + } + + /** List the perspectives (Overview / Data Laboratory / Preview) and the active one. */ + public JsonObject getPerspective() { + org.gephi.perspective.api.PerspectiveController pc = + Lookup.getDefault().lookup(org.gephi.perspective.api.PerspectiveController.class); + if (pc == null) { + return error("No perspective controller (headless?)"); + } + try { + org.gephi.perspective.spi.Perspective selected = pc.getSelectedPerspective(); + JsonObject r = success("Perspectives listed"); + r.addProperty("selected", selected == null ? null : selected.getName()); + com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); + for (org.gephi.perspective.spi.Perspective p : pc.getPerspectives()) { + JsonObject o = new JsonObject(); + o.addProperty("name", p.getName()); + o.addProperty("display_name", p.getDisplayName()); + o.addProperty("selected", p == selected); + arr.add(o); + } + r.add("perspectives", arr); + return r; + } catch (Exception e) { + return failure("List perspectives failed: ", e); + } + } + + /** Switch the active perspective (tab) by name or display name (case-insensitive). */ + public JsonObject switchPerspective(String name) { + org.gephi.perspective.api.PerspectiveController pc = + Lookup.getDefault().lookup(org.gephi.perspective.api.PerspectiveController.class); + if (pc == null) { + return error("No perspective controller (headless?)"); + } + if (name == null) { + return error("Missing 'name'"); + } + org.gephi.perspective.spi.Perspective match = null; + for (org.gephi.perspective.spi.Perspective p : pc.getPerspectives()) { + if (name.equalsIgnoreCase(p.getName()) || name.equalsIgnoreCase(p.getDisplayName())) { + match = p; + break; + } + } + if (match == null) { + return error("Perspective not found: " + name); + } + final org.gephi.perspective.spi.Perspective target = match; + // Switching the perspective mutates the NetBeans window system — do it on the EDT. + return runOnEDT(() -> { + pc.selectPerspective(target); + JsonObject r = success("Switched to perspective: " + target.getDisplayName()); + r.addProperty("selected", target.getName()); + return r; + }); + } + + // ─── Filters (Group C) ─────────────────────────────────────────── + + /** + * Every filter builder available, static and dynamic. Static builders + * (DegreeRange, KCore, GiantComponent, Ego, …) come straight from Lookup; + * per-column attribute builders (AttributeEqual/Range/NonNull on each + * column) come from CategoryBuilder.getBuilders(workspace) and only exist + * once a graph with columns is loaded. + */ + private java.util.List allFilterBuilders(Workspace ws) { + java.util.List out = new java.util.ArrayList<>(); + for (FilterBuilder b : Lookup.getDefault().lookupAll(FilterBuilder.class)) { + out.add(b); + } + for (CategoryBuilder cb : Lookup.getDefault().lookupAll(CategoryBuilder.class)) { + try { + FilterBuilder[] bs = cb.getBuilders(ws); + if (bs != null) { + java.util.Collections.addAll(out, bs); + } + } catch (Exception ignore) { /* some category builders need a specific state */ + } + } + return out; + } + + /** + * Every filter with a name that can be typed back. Gephi names the per-column filters with + * HTML (the column in black, its type in grey), and gives the same name to that column's + * Equal, Non-null and Partition Count filters, so a name alone reached only the first. Those + * filters are named "Category: column type" instead, e.g. "Equal: group String (Node)". + */ + private java.util.List> namedFilterBuilders(Workspace ws) { + java.util.List> out = new java.util.ArrayList<>(); + for (FilterBuilder b : Lookup.getDefault().lookupAll(FilterBuilder.class)) { + try { + out.add(Map.entry(plainText(b.getName()), b)); + } catch (Exception ignore) { + // A filter whose name cannot be read is left out of the list. + } + } + for (CategoryBuilder cb : Lookup.getDefault().lookupAll(CategoryBuilder.class)) { + try { + FilterBuilder[] bs = cb.getBuilders(ws); + if (bs == null) { + continue; + } + for (FilterBuilder b : bs) { + String category = b.getCategory() != null ? b.getCategory().getName() : null; + String name = plainText(b.getName()); + out.add(Map.entry(category == null ? name : plainText(category) + ": " + name, b)); + } + } catch (Exception ignore) { /* some category builders need a specific state */ + } + } + return out; + } + + /** The text of a Gephi label that may carry HTML markup. */ + static String plainText(String label) { + if (label == null) { + return ""; + } + return label.replaceAll("<[^>]*>", " ").replace("&", "&").replace("<", "<") + .replace(">", ">").replaceAll("\\s+", " ").trim(); + } + + /** The filter builder a name refers to: the plain name, or Gephi's own label. */ + private FilterBuilder findFilterBuilder(Workspace ws, String name) { + if (name == null) { + return null; + } + FilterBuilder byLabel = null; + for (Map.Entry e : namedFilterBuilders(ws)) { + if (name.equalsIgnoreCase(e.getKey())) { + return e.getValue(); + } + try { + if (byLabel == null && name.equalsIgnoreCase(e.getValue().getName())) { + byLabel = e.getValue(); + } + } catch (Exception ignore) { + // A filter whose name cannot be read cannot match by name. + } + } + return byLabel; + } + + /** Coerce a JSON value to a filter property's type; handles Range from a [lo, hi] pair. */ + static Object convertFilterProperty(Object val, Class type) { + if (val == null) { + return null; + } + if (type == org.gephi.filters.api.Range.class) { + java.util.List pair = null; + if (val instanceof java.util.List) { + pair = (java.util.List) val; + } else if (val instanceof com.google.gson.JsonArray) { + java.util.List l = new java.util.ArrayList<>(); + for (com.google.gson.JsonElement e : (com.google.gson.JsonArray) val) { + l.add(e.getAsDouble()); + } + pair = l; + } + if (pair == null || pair.size() != 2) { + return null; + } + double loD = pair.get(0) instanceof Number ? ((Number) pair.get(0)).doubleValue() + : Double.parseDouble(pair.get(0).toString()); + double hiD = pair.get(1) instanceof Number ? ((Number) pair.get(1)).doubleValue() + : Double.parseDouble(pair.get(1).toString()); + // Range requires both bounds to be the SAME Number class. Use Integer when + // both are whole (degree/count filters), Double otherwise (continuous columns). + boolean whole = loD == Math.floor(loD) && hiD == Math.floor(hiD) + && !Double.isInfinite(loD) && !Double.isInfinite(hiD); + if (whole) { + return new org.gephi.filters.api.Range((int) loD, (int) hiD); + } + return new org.gephi.filters.api.Range(loD, hiD); + } + return convertLayoutProperty(val, type); + } + + public JsonObject listFilters() { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No workspace open"); + } + JsonArray arr = new JsonArray(); + for (Map.Entry e : namedFilterBuilders(ws)) { + FilterBuilder b = e.getValue(); + JsonObject o = new JsonObject(); + o.addProperty("name", e.getKey()); + try { + o.addProperty("category", b.getCategory() == null ? null : b.getCategory().getName()); + } catch (Exception ignore) { + // The category is optional. + } + try { + o.addProperty("description", b.getDescription()); + } catch (Exception ignore) { + // The description is optional. + } + // Introspect the filter's settable properties so callers know what params to pass. + try { + Filter f = b.getFilter(ws); + if (f != null && f.getProperties() != null) { + JsonArray props = new JsonArray(); + for (FilterProperty p : f.getProperties()) { + JsonObject po = new JsonObject(); + po.addProperty("name", p.getName()); + po.addProperty("type", p.getValueType() == null ? null : p.getValueType().getSimpleName()); + props.add(po); + } + o.add("properties", props); + } + } catch (Exception ignore) { /* introspection best-effort */ + } + arr.add(o); + } + JsonObject r = success("Filters listed"); + r.add("filters", arr); + return r; + } + + public JsonObject applyFilter(String name, Map params, String action, String column) { + FilterController fc = Lookup.getDefault().lookup(FilterController.class); + if (fc == null) { + return error("No filter controller available"); + } + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No workspace open"); + } + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (name == null) { + return error("Missing 'name'"); + } + + FilterBuilder builder = findFilterBuilder(ws, name); + if (builder == null) { + return error("Filter not found: " + name + " (gephi_list_filters lists them)"); + } + + Filter filter = builder.getFilter(ws); + if (filter == null) { + return error("Filter builder produced no filter: " + name); + } + + // Set each named property; report the valid names if a param doesn't match. + FilterProperty[] props = filter.getProperties(); + if (params != null && !params.isEmpty()) { + java.util.List propNames = new java.util.ArrayList<>(); + if (props != null) { + for (FilterProperty p : props) { + propNames.add(p.getName()); + } + } + for (Map.Entry e : params.entrySet()) { + FilterProperty match = null; + if (props != null) { + for (FilterProperty p : props) { + if (e.getKey().equalsIgnoreCase(p.getName())) { + match = p; + break; + } + } + } + if (match == null) { + return error("Unknown filter property '" + e.getKey() + "' for " + name + + " — valid properties: " + propNames); + } + Object converted = convertFilterProperty(e.getValue(), match.getValueType()); + if (converted == null) { + return error("Could not coerce '" + e.getKey() + "' to " + match.getValueType().getSimpleName() + + " (Range wants a [lo, hi] pair)"); + } + try { + match.setValue(converted); + } catch (Exception ex) { + return failure("Failed to set '" + e.getKey() + "': ", ex); + } + } + } + + int nodesBefore = gm.getGraphVisible().getNodeCount(); + int edgesBefore = gm.getGraphVisible().getEdgeCount(); + + // Validate the action BEFORE touching the filter model. Adding the query first + // meant an unknown action returned an error having already changed the visible + // graph, so a caller that trusted the error saw a silently filtered graph. + String act = action == null ? "select" : action.toLowerCase(); + switch (act) { + case "select": + case "visible": + case "new_workspace": + break; + case "column": + if (column == null) { + return error("action=column requires a 'column' name"); + } + break; + default: + return error("Unknown action: " + action + " (use select|new_workspace|column)"); + } + + Query query = fc.createQuery(filter); + fc.add(query); + + // All three FilterController operations below end in Gephi's own BLOCKING + // writeLock() (filterVisible via setVisibleView; the two exports process the + // query through the same path). Hold our deadlock-safe lock first so those + // calls re-enter instead of queuing behind the renderer — exactly the + // mitigation resetFilters uses, and these run on an HTTP request thread too. + Graph lockGraph = gm.getGraph(); + JsonObject r; + switch (act) { + case "select": + case "visible": + lockWrite(lockGraph); + try { + fc.filterVisible(query); + } finally { + unlockWrite(lockGraph); + } + r = success("Filter applied to the visible graph"); + r.addProperty("nodes_before", nodesBefore); + r.addProperty("edges_before", edgesBefore); + // filterVisible ends in setVisibleView, which does not finish swapping the + // view before it returns. Reading the counts straight away reported the + // pre-filter numbers, telling the caller the filter removed nothing when it + // had removed half the graph. Wait briefly for the view to settle, and say + // so rather than publishing a number that has not stopped moving. + boolean settled = awaitVisibleViewSettled(gm, nodesBefore); + r.addProperty("nodes_after", gm.getGraphVisible().getNodeCount()); + r.addProperty("edges_after", gm.getGraphVisible().getEdgeCount()); + if (!settled) { + r.addProperty("counts_settled", false); + } + break; + case "new_workspace": + // Materializes the filtered subgraph into a fresh workspace — the + // memory-safe way to filter repeatedly (hidden GraphView elements + // otherwise stay resident). + lockWrite(lockGraph); + try { + fc.exportToNewWorkspace(query); + } finally { + unlockWrite(lockGraph); + } + r = success("Filtered subgraph exported to a new workspace"); + break; + case "column": + if (column == null) { + return error("action=column requires a 'column' name"); + } + lockWrite(lockGraph); + try { + fc.exportToColumn(column, query); + } finally { + unlockWrite(lockGraph); + } + r = success("Filter membership written to boolean column: " + column); + r.addProperty("column", column); + break; + default: + return error("Unknown action: " + action + " (use select|new_workspace|column)"); + } + r.addProperty("filter", name); + return r; + } + + /** + * Waits briefly for the visible view to stop changing after a filter is applied. + * Returns true once two consecutive reads agree (and, when the filter actually + * removed something, once the count has moved off its pre-filter value); false if + * it was still moving when the budget ran out, so the caller can say the number is + * provisional instead of presenting a moving value as final. + */ + private static boolean awaitVisibleViewSettled(GraphModel gm, int before) { + final long budgetMs = 1500; + final long deadline = System.currentTimeMillis() + budgetMs; + int last = -1; + boolean moved = false; + while (System.currentTimeMillis() < deadline) { + int now = gm.getGraphVisible().getNodeCount(); + if (now != before) { + moved = true; + } + if (now == last && (moved || now != before)) { + return true; + } + last = now; + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + // A filter that legitimately keeps every node never moves off `before`; treat a + // stable reading as settled rather than reporting it as provisional forever. + return gm.getGraphVisible().getNodeCount() == last; + } + + // ─── Combined filters ──────────────────────────────────────────── + + /** A filter from its name and settings, or the reason it could not be made. */ + private Object buildFilter(Workspace ws, String name, Map params) { + if (name == null) { + return "Each filter needs a 'name'"; + } + FilterBuilder builder = findFilterBuilder(ws, name); + if (builder == null) { + return "Filter not found: " + name + " (gephi_list_filters lists them)"; + } + Filter filter = builder.getFilter(ws); + if (filter == null) { + return "Filter builder produced no filter: " + name; + } + FilterProperty[] props = filter.getProperties(); + if (params != null) { + for (Map.Entry e : params.entrySet()) { + FilterProperty match = null; + java.util.List names = new java.util.ArrayList<>(); + if (props != null) { + for (FilterProperty p : props) { + names.add(p.getName()); + if (match == null && e.getKey().equalsIgnoreCase(p.getName())) { + match = p; + } + } + } + if (match == null) { + return "Unknown filter property '" + e.getKey() + "' for " + name + " — valid properties: " + names; + } + Object converted = convertFilterProperty(e.getValue(), match.getValueType()); + if (converted == null) { + return "Could not coerce '" + e.getKey() + "' to " + match.getValueType().getSimpleName() + + " (Range wants a [lo, hi] pair)"; + } + try { + match.setValue(converted); + } catch (Exception ex) { + return "Failed to set '" + e.getKey() + "': " + ex.getMessage(); + } + } + } + return filter; + } + + private FilterBuilder operatorBuilder(Workspace ws, String simpleName) { + for (FilterBuilder b : allFilterBuilders(ws)) { + if (b.getClass().getSimpleName().equals(simpleName)) { + return b; + } + } + return null; + } + + /** + * Applies several filters together: {@code combine} "all" keeps what every filter keeps, + * "any" what at least one keeps. A filter marked {@code exclude} keeps the opposite of what + * it would keep alone. {@code dry_run} counts what would remain without changing the view. + * The combined query shows in Gephi's Filters panel as its operator with the filters under it. + */ + @SuppressWarnings("unchecked") + public JsonObject applyFilters(java.util.List> specs, String combine, String action, + String column, boolean dryRun) { + FilterController fc = Lookup.getDefault().lookup(FilterController.class); + if (fc == null) { + return error("No filter controller available"); + } + Workspace ws = currentWorkspace(); + GraphModel gm = currentGraphModel(); + if (ws == null || gm == null) { + return error("No workspace open"); + } + if (specs == null || specs.isEmpty()) { + return error("Give at least one filter in 'filters'"); + } + String mode = combine == null ? "all" : combine.toLowerCase(java.util.Locale.ROOT); + if (!mode.equals("all") && !mode.equals("any")) { + return error("combine must be \"all\" or \"any\""); + } + String act = action == null ? "select" : action.toLowerCase(java.util.Locale.ROOT); + if (!dryRun && !act.equals("select") && !act.equals("new_workspace") && !act.equals("column")) { + return error("Unknown action: " + action + " (use select|new_workspace|column)"); + } + if (!dryRun && act.equals("column") && column == null) { + return error("action=column requires a 'column' name"); + } + + java.util.List parts = new java.util.ArrayList<>(); + JsonArray applied = new JsonArray(); + for (Map spec : specs) { + String name = spec.get("name") == null ? null : spec.get("name").toString(); + Object params = spec.get("params"); + Object built = buildFilter(ws, name, params instanceof Map ? (Map) params : null); + if (built instanceof String) { + return error((String) built); + } + Filter filter = (Filter) built; + Query q = fc.createQuery(filter); + boolean exclude = Boolean.TRUE.equals(spec.get("exclude")) + || "true".equalsIgnoreCase(String.valueOf(spec.get("exclude"))); + if (exclude) { + String not = filter instanceof org.gephi.filters.spi.EdgeFilter ? "NOTBuilderEdge" : "NOTBuilderNode"; + FilterBuilder nb = operatorBuilder(ws, not); + if (nb == null) { + return error("Gephi's NOT operator is not available"); + } + Query nq = fc.createQuery(nb.getFilter(ws)); + fc.setSubQuery(nq, q); + q = nq; + } + parts.add(q); + applied.add((exclude ? "NOT " : "") + name); + } + Query query; + if (parts.size() == 1) { + query = parts.get(0); + } else { + FilterBuilder ob = operatorBuilder(ws, mode.equals("all") ? "INTERSECTIONBuilder" : "UNIONBuilder"); + if (ob == null) { + return error("Gephi's " + (mode.equals("all") ? "INTERSECTION" : "UNION") + + " operator is not available"); + } + query = fc.createQuery(ob.getFilter(ws)); + for (Query part : parts) { + fc.setSubQuery(query, part); + } + } + + int nodesBefore = gm.getGraph().getNodeCount(); + int edgesBefore = gm.getGraph().getEdgeCount(); + Graph lockGraph = gm.getGraph(); + JsonObject r; + if (dryRun) { + org.gephi.graph.api.GraphView view; + lockWrite(lockGraph); + try { + view = fc.filter(query); + } finally { + unlockWrite(lockGraph); + } + Graph kept = gm.getGraph(view); + r = success("Dry run: nothing was changed"); + r.addProperty("nodes_kept", kept.getNodeCount()); + r.addProperty("edges_kept", kept.getEdgeCount()); + r.addProperty("nodes_removed", nodesBefore - kept.getNodeCount()); + r.addProperty("edges_removed", edgesBefore - kept.getEdgeCount()); + if (!view.isMainView()) { + gm.destroyView(view); + } + } else { + fc.add(query); + lockWrite(lockGraph); + try { + switch (act) { + case "new_workspace": + fc.exportToNewWorkspace(query); + break; + case "column": + fc.exportToColumn(column, query); + break; + default: + fc.filterVisible(query); + } + } finally { + unlockWrite(lockGraph); + } + if (act.equals("select")) { + r = success("Filters applied to the visible graph"); + final boolean settled = awaitVisibleViewSettled(gm, gm.getGraphVisible().getNodeCount()); + r.addProperty("nodes_before", nodesBefore); + r.addProperty("edges_before", edgesBefore); + r.addProperty("nodes_after", gm.getGraphVisible().getNodeCount()); + r.addProperty("edges_after", gm.getGraphVisible().getEdgeCount()); + if (!settled) { + r.addProperty("counts_settled", false); + } + } else if (act.equals("new_workspace")) { + r = success("Filtered subgraph exported to a new workspace"); + } else { + r = success("Filter membership written to boolean column: " + column); + r.addProperty("column", column); + } + } + r.addProperty("combine", mode); + r.add("filters", applied); + return r; + } + + // ─── Time ──────────────────────────────────────────────────────── + + /** + * Gives nodes or edges their time from one or two columns: a start and an optional end, + * as numbers (years, for instance) or dates. Afterwards the network has time data, so the + * timeline, time slices and dynamic statistics work. Needs a workspace that stores time as + * intervals, which is Gephi's default. + */ + public JsonObject setTimeFromColumns(String target, String startName, String endName, String dateFormat) { + return setTimeFromColumns(target, startName, endName, dateFormat, false); + } + + /** {@code checkOnly}: report whether the change would be refused, without changing anything. */ + public JsonObject setTimeFromColumns(String target, String startName, String endName, String dateFormat, + boolean checkOnly) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (startName == null && endName == null) { + return error("Name a 'start' column, an 'end' column, or both"); + } + if (gm.getConfiguration().getTimeRepresentation() != org.gephi.graph.api.TimeRepresentation.INTERVAL) { + return error("This workspace stores time as timestamps, so start and end columns cannot be" + + " turned into intervals. Import the data into a new workspace first."); + } + Table table = tableFor(gm, target); + Column start = startName == null ? null : findColumn(table, startName); + Column end = endName == null ? null : findColumn(table, endName); + if (startName != null && start == null) { + return error("Column not found: " + startName); + } + if (endName != null && end == null) { + return error("Column not found: " + endName); + } + org.gephi.datalab.api.AttributeColumnsMergeStrategiesController mc = + Lookup.getDefault().lookup(org.gephi.datalab.api.AttributeColumnsMergeStrategiesController.class); + if (mc == null) { + return error("No datalab controller available"); + } + boolean numeric = (start == null || isNumberColumn(start)) && (end == null || isNumberColumn(end)); + if (!numeric) { + if (dateFormat == null) { + return error("The columns hold text, so give 'date_format' as a Java date pattern," + + " for example \"yyyy-MM-dd\" or \"dd/MM/yyyy\""); + } + try { + new java.text.SimpleDateFormat(dateFormat); + } catch (IllegalArgumentException e) { + return error("Not a date pattern: " + dateFormat); + } + } + if (checkOnly) { + return success("Ready"); + } + Graph g = gm.getGraph(); + int withTime = 0; + lockWrite(g); + try { + if (numeric) { + mc.mergeNumericColumnsToTimeInterval(table, start, end, Double.NEGATIVE_INFINITY, + Double.POSITIVE_INFINITY); + } else { + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat(dateFormat); + mc.mergeDateColumnsToTimeInterval(table, start, end, fmt, null, null); + } + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + if (el.getIntervals().length > 0) { + withTime++; + } + } + } catch (Exception e) { + return failure("Could not set time from the columns: ", e); + } finally { + unlockWrite(g); + } + JsonObject r = success("Time set on " + withTime + " " + ("edge".equalsIgnoreCase(target) ? "edges" : "nodes")); + r.addProperty("with_time", withTime); + org.gephi.graph.api.Interval b = gm.getTimeBounds(); + if (b != null && !Double.isInfinite(b.getLow())) { + r.addProperty("time_min", b.getLow()); + } + if (b != null && !Double.isInfinite(b.getHigh())) { + r.addProperty("time_max", b.getHigh()); + } + return r; + } + + private static boolean isNumberColumn(Column c) { + return Number.class.isAssignableFrom(c.getTypeClass()) + || (c.getTypeClass().isPrimitive() && c.getTypeClass() != boolean.class && c.getTypeClass() != char.class); + } + + /** + * True when an element is present at some moment in [low, high]. {@code rep} is how the + * workspace stores time; an element with no time data counts as always present. + */ + static boolean presentIn(org.gephi.graph.api.Element e, double low, double high, + org.gephi.graph.api.TimeRepresentation rep) { + if (rep == org.gephi.graph.api.TimeRepresentation.INTERVAL) { + org.gephi.graph.api.Interval[] intervals = e.getIntervals(); + if (intervals.length == 0) { + return true; + } + for (org.gephi.graph.api.Interval i : intervals) { + if (i.getLow() <= high && i.getHigh() >= low) { + return true; + } + } + return false; + } + double[] stamps = e.getTimestamps(); + if (stamps.length == 0) { + return true; + } + for (double t : stamps) { + if (t >= low && t <= high) { + return true; + } + } + return false; + } + + /** + * Copies what is present between {@code low} and {@code high} into a new workspace and + * opens it: nodes present then, and edges present then between them. The network itself, + * and Gephi's timeline, are left as they were. + */ + public JsonObject timeSlice(double low, double high) { + if (!(low <= high)) { + return error("'start' must not be after 'end'"); + } + final ProjectController pc = getProjectController(); + Workspace source = currentWorkspace(); + if (source == null) { + return error("No workspace open"); + } + GraphModel gm = getGraphController().getGraphModel(source); + if (!gm.isDynamic()) { + return error("This network has no time data. gephi_set_time_from_columns gives it time from" + + " start and end columns."); + } + Graph g = gm.getGraph(); + java.util.List keep = new java.util.ArrayList<>(); + java.util.Set dropEdges = new java.util.HashSet<>(); + org.gephi.graph.api.TimeRepresentation rep = gm.getConfiguration().getTimeRepresentation(); + lockRead(g); + try { + for (Node n : g.getNodes().toArray()) { + if (presentIn(n, low, high, rep)) { + keep.add(n); + } + } + for (Edge e : g.getEdges().toArray()) { + if (!presentIn(e, low, high, rep)) { + dropEdges.add(e.getId()); + } + } + } finally { + g.readUnlock(); + } + String name = workspaceName(source) + " " + fmtTime(low) + "–" + fmtTime(high); + Workspace[] made = new Workspace[1]; + onProjectThread(() -> { + // A workspace's settings (time format, id type) are fixed when it is made. + made[0] = pc.newWorkspace(pc.getCurrentProject(), gm.getConfiguration()); + GraphModel target = getGraphController().getGraphModel(made[0]); + target.bridge().copyNodes(keep.toArray(new Node[0])); + Graph tg = target.getGraph(); + tg.writeLock(); + try { + for (Object id : dropEdges) { + Edge e = tg.getEdge(id); + if (e != null) { + tg.removeEdge(e); + } + } + } finally { + tg.writeUnlock(); + } + pc.renameWorkspace(made[0], name); + pc.openWorkspace(made[0]); + return null; + }); + Graph sliced = getGraphController().getGraphModel(made[0]).getGraph(); + JsonObject r = success("Opened a workspace with the network from " + fmtTime(low) + " to " + fmtTime(high)); + r.addProperty("workspace_id", made[0].getId()); + r.addProperty("workspace_name", name); + r.addProperty("node_count", sliced.getNodeCount()); + r.addProperty("edge_count", sliced.getEdgeCount()); + r.addProperty("source_node_count", g.getNodeCount()); + r.addProperty("source_edge_count", g.getEdgeCount()); + return r; + } + + private static String workspaceName(Workspace ws) { + String n = ws.getName(); + return n == null || n.isBlank() ? "Workspace " + ws.getId() : n; + } + + // ─── Shortest path ─────────────────────────────────────────────── + + /** The result of a shortest-path search: the path, its length, and how many paths tie. */ + static final class PathResult { + final java.util.List nodes; + final java.util.List edges; + final double length; + final long tiedPaths; + + PathResult(java.util.List nodes, java.util.List edges, double length, long tiedPaths) { + this.nodes = nodes; + this.edges = edges; + this.length = length; + this.tiedPaths = tiedPaths; + } + } + + /** + * Dijkstra's shortest path from {@code from} to {@code to}, or null when none exists. + * {@code weighting}: "none" counts steps, "distance" reads an edge's weight as its length, + * "strength" reads a heavier edge as a closer tie (length 1 / weight). Directed edges are + * followed only forwards when {@code followDirection} is set. Also counts how many + * different paths share the shortest length. + */ + static PathResult shortestPath(Graph g, Node from, Node to, String weighting, boolean followDirection) { + java.util.Map dist = new java.util.HashMap<>(); + java.util.Map ways = new java.util.HashMap<>(); + final java.util.Map via = new java.util.HashMap<>(); + java.util.PriorityQueue queue = new java.util.PriorityQueue<>( + (a, b) -> Double.compare((Double) a[1], (Double) b[1])); + dist.put(from, 0.0); + ways.put(from, 1L); + queue.add(new Object[] {from, 0.0}); + java.util.Set done = new java.util.HashSet<>(); + final double eps = 1e-9; + while (!queue.isEmpty()) { + Object[] head = queue.poll(); + Node n = (Node) head[0]; + if (!done.add(n)) { + continue; + } + if (n == to) { + break; + } + for (Edge e : g.getEdges(n).toArray()) { + Node other = g.getOpposite(n, e); + if (followDirection && e.isDirected() && e.getSource() != n) { + continue; + } + if (e.isSelfLoop() || done.contains(other)) { + continue; + } + double w = e.getWeight(); + double step = "distance".equals(weighting) ? w : "strength".equals(weighting) ? 1.0 / w : 1.0; + if (!(step > 0) || Double.isInfinite(step)) { + continue; + } + double d = dist.get(n) + step; + Double known = dist.get(other); + if (known == null || d < known - eps) { + dist.put(other, d); + ways.put(other, ways.get(n)); + via.put(other, e); + queue.add(new Object[] {other, d}); + } else if (Math.abs(d - known) <= eps) { + ways.merge(other, ways.get(n), Long::sum); + } + } + } + if (!dist.containsKey(to) || !done.contains(to)) { + return null; + } + java.util.LinkedList nodes = new java.util.LinkedList<>(); + java.util.LinkedList edges = new java.util.LinkedList<>(); + Node cur = to; + nodes.addFirst(cur); + while (cur != from) { + Edge e = via.get(cur); + edges.addFirst(e); + cur = g.getOpposite(cur, e); + nodes.addFirst(cur); + } + return new PathResult(nodes, edges, dist.get(to), ways.get(to)); + } + + public JsonObject findShortestPath(String fromId, String toId, String weighting, boolean followDirection, + String markColumn) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + String w = weighting == null ? "none" : weighting.toLowerCase(java.util.Locale.ROOT); + if (!w.equals("none") && !w.equals("distance") && !w.equals("strength")) { + return error("weighting must be \"none\", \"distance\" or \"strength\""); + } + Graph g = gm.getGraph(); + PathResult path; + lockRead(g); + try { + Node from = g.getNode(fromId); + Node to = g.getNode(toId); + if (from == null) { + return error("Node not found: " + fromId); + } + if (to == null) { + return error("Node not found: " + toId); + } + path = from == to ? new PathResult(java.util.List.of(from), java.util.List.of(), 0, 1) + : shortestPath(g, from, to, w, followDirection); + } finally { + g.readUnlock(); + } + if (path == null) { + JsonObject r = success("No path from " + fromId + " to " + toId + + (followDirection && gm.isDirected() ? " following edge directions" : "")); + r.addProperty("found", false); + return r; + } + JsonArray nodes = new JsonArray(); + for (Node n : path.nodes) { + JsonObject o = new JsonObject(); + o.addProperty("id", n.getId().toString()); + o.addProperty("label", n.getLabel()); + nodes.add(o); + } + JsonObject r = success("Path of " + path.edges.size() + " step(s)"); + r.addProperty("found", true); + r.addProperty("steps", path.edges.size()); + if (!w.equals("none")) { + r.addProperty("length", path.length); + } + r.addProperty("weighting", w); + r.addProperty("equally_short_paths", path.tiedPaths); + r.add("path", nodes); + if (markColumn != null) { + lockWrite(g); + try { + Column nc = findColumn(gm.getNodeTable(), markColumn); + if (nc == null) { + nc = gm.getNodeTable().addColumn(markColumn, Boolean.class); + } + Column ec = findColumn(gm.getEdgeTable(), markColumn); + if (ec == null) { + ec = gm.getEdgeTable().addColumn(markColumn, Boolean.class); + } + if (nc.getTypeClass() != Boolean.class || ec.getTypeClass() != Boolean.class) { + return error("Column '" + markColumn + + "' already exists and is not true/false; choose another name"); + } + for (Node n : g.getNodes().toArray()) { + n.setAttribute(nc, path.nodes.contains(n)); + } + java.util.Set onPath = new java.util.HashSet<>(path.edges); + for (Edge e : g.getEdges().toArray()) { + e.setAttribute(ec, onPath.contains(e)); + } + } finally { + unlockWrite(g); + } + r.addProperty("mark_column", markColumn); + } + return r; + } + + // ─── Column tidy-up ────────────────────────────────────────────── + + /** + * Tidies one column. {@code action}: "delete" removes it; "rename" gives it {@code newName}; + * "convert" changes its type to {@code type}, reporting values that could not convert; + * "fill_empty" writes {@code value} where the column is empty; "clear" empties it. + */ + public JsonObject editColumn(String target, String columnName, String action, String value, + String type, String newName) { + return editColumn(target, columnName, action, value, type, newName, false); + } + + /** {@code checkOnly}: report whether the edit would be refused, without changing anything. */ + public JsonObject editColumn(String target, String columnName, String action, String value, + String type, String newName, boolean checkOnly) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (columnName == null || action == null) { + return error("Give 'column' and 'action'"); + } + org.gephi.datalab.api.AttributeColumnsController acc = + Lookup.getDefault().lookup(org.gephi.datalab.api.AttributeColumnsController.class); + if (acc == null) { + return error("No datalab controller available"); + } + Table table = tableFor(gm, target); + Column col = findColumn(table, columnName); + if (col == null) { + return error("Column not found: " + columnName); + } + String title = col.getTitle(); + Graph g = gm.getGraph(); + lockWrite(g); + try { + switch (action.toLowerCase(java.util.Locale.ROOT)) { + case "delete": { + if (!acc.canDeleteColumn(col)) { + return error("Gephi keeps the '" + title + "' column; it cannot be deleted"); + } + if (checkOnly) { + return success("Ready"); + } + acc.deleteAttributeColumn(table, col); + return success("Deleted column " + title); + } + case "rename": { + if (newName == null || newName.isBlank()) { + return error("Give the new name in 'new_name'"); + } + if (!acc.canDeleteColumn(col)) { + return error("Gephi's own '" + title + "' column cannot be renamed"); + } + if (findColumn(table, newName) != null) { + return error("A column named " + newName + " already exists"); + } + if (checkOnly) { + return success("Ready"); + } + Column copy = acc.duplicateColumn(table, col, newName, col.getTypeClass()); + acc.deleteAttributeColumn(table, col); + JsonObject r = success("Renamed " + title + " to " + copy.getTitle()); + r.addProperty("column", copy.getId()); + return r; + } + case "convert": { + Class cls = typeStringToClass(type); + if (cls == null) { + return error("Give 'type': string, integer, long, float, double or boolean"); + } + if (!acc.canDeleteColumn(col)) { + return error("Gephi's own '" + title + "' column cannot be converted"); + } + if (checkOnly) { + return success("Ready"); + } + final int before = countValues(gm, target, col); + Column tmp = acc.duplicateColumn(table, col, title + " (converting)", cls); + acc.deleteAttributeColumn(table, col); + Column converted = acc.duplicateColumn(table, tmp, title, cls); + acc.deleteAttributeColumn(table, tmp); + int after = countValues(gm, target, converted); + JsonObject r = success("Converted " + title + " to " + cls.getSimpleName()); + r.addProperty("column", converted.getId()); + r.addProperty("values_lost", before - after); + if (before > after) { + r.addProperty("warning", (before - after) + " value(s) could not be read as " + + cls.getSimpleName() + " and are now empty"); + } + return r; + } + case "fill_empty": { + if (value == null) { + return error("Give the value to write in 'value'"); + } + if (!acc.canChangeColumnData(col)) { + return error("The '" + title + "' column cannot be changed"); + } + if (checkOnly) { + return success("Ready"); + } + java.util.List nodes = new java.util.ArrayList<>(); + java.util.List edges = new java.util.ArrayList<>(); + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + if (el.getAttribute(col) != null) { + continue; + } + if (el instanceof Node) { + nodes.add((Node) el); + } else { + edges.add((Edge) el); + } + } + if (!nodes.isEmpty()) { + acc.fillNodesColumnWithValue(nodes.toArray(new Node[0]), col, value); + } + if (!edges.isEmpty()) { + acc.fillEdgesColumnWithValue(edges.toArray(new Edge[0]), col, value); + } + JsonObject r = success("Filled " + (nodes.size() + edges.size()) + " empty value(s) in " + title); + r.addProperty("filled", nodes.size() + edges.size()); + return r; + } + case "clear": { + if (!acc.canClearColumnData(col)) { + return error("The '" + title + "' column cannot be cleared"); + } + if (checkOnly) { + return success("Ready"); + } + acc.clearColumnData(table, col); + return success("Cleared every value in " + title); + } + default: + return error("Unknown action: " + action + " (use delete|rename|convert|fill_empty|clear)"); + } + } catch (Exception e) { + return failure("Failed: ", e); + } finally { + unlockWrite(g); + } + } + + private static int countValues(GraphModel gm, String target, Column col) { + int n = 0; + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + if (el.getAttribute(col) != null) { + n++; + } + } + return n; + } + + // ─── Data Laboratory (Group D) ─────────────────────────────────── + + private static Table tableFor(GraphModel gm, String target) { + return "edge".equalsIgnoreCase(target) ? gm.getEdgeTable() : gm.getNodeTable(); + } + + private static org.gephi.graph.api.Element[] elementsFor(GraphModel gm, String target) { + Graph g = gm.getGraph(); + return "edge".equalsIgnoreCase(target) ? g.getEdges().toArray() : g.getNodes().toArray(); + } + + /** + * Value -> count over one column. Pure GraphModel logic (no datalab + * controller / running Gephi needed), so it is unit-testable against an + * in-memory model. + */ + static JsonObject columnValueFrequenciesCore(GraphModel gm, String target, String columnId) { + Table table = tableFor(gm, target); + Column col = findColumn(table, columnId); + if (col == null) { + return error("Column not found: " + columnId); + } + java.util.LinkedHashMap freq = new java.util.LinkedHashMap<>(); + int total = 0; + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + Object v = el.getAttribute(col); + String key = v == null ? "" : v.toString(); + freq.merge(key, 1, Integer::sum); + total++; + } + JsonObject r = success("Column value frequencies computed"); + r.addProperty("column", columnId); + r.addProperty("target", "edge".equalsIgnoreCase(target) ? "edge" : "node"); + r.addProperty("total", total); + r.addProperty("distinct_values", freq.size()); + JsonObject f = new JsonObject(); + for (Map.Entry e : freq.entrySet()) { + f.addProperty(e.getKey(), e.getValue()); + } + r.add("frequencies", f); + return r; + } + + /** + * Groups of elements that share a value in one column (size >= 2). Pure + * GraphModel logic, unit-testable. caseSensitive controls string matching. + */ + static JsonObject detectDuplicatesCore(GraphModel gm, String target, String columnId, boolean caseSensitive) { + Table table = tableFor(gm, target); + Column col = findColumn(table, columnId); + if (col == null) { + return error("Column not found: " + columnId); + } + java.util.LinkedHashMap> groups = new java.util.LinkedHashMap<>(); + for (org.gephi.graph.api.Element el : elementsFor(gm, target)) { + Object v = el.getAttribute(col); + if (v == null) { + continue; + } + String key = v.toString(); + if (!caseSensitive) { + key = key.toLowerCase(); + } + groups.computeIfAbsent(key, k -> new java.util.ArrayList<>()).add(String.valueOf(el.getId())); + } + JsonArray dupes = new JsonArray(); + int groupCount = 0; + for (java.util.List ids : groups.values()) { + if (ids.size() >= 2) { + groupCount++; + JsonArray a = new JsonArray(); + for (String id : ids) { + a.add(id); + } + dupes.add(a); + } + } + JsonObject r = success("Duplicate detection complete"); + r.addProperty("column", columnId); + r.addProperty("group_count", groupCount); + r.add("duplicate_groups", dupes); + return r; + } + + public JsonObject columnValueFrequencies(String target, String columnId) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (columnId == null) { + return error("Missing 'column'"); + } + return columnValueFrequenciesCore(gm, target, columnId); + } + + public JsonObject detectDuplicates(String target, String columnId, boolean caseSensitive) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (columnId == null) { + return error("Missing 'column'"); + } + return detectDuplicatesCore(gm, target, columnId, caseSensitive); + } + + /** Merge several nodes into one, reassigning edges; deletes the merged-away nodes. */ + public JsonObject mergeNodes(java.util.List ids, String intoId) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (ids == null || ids.isEmpty()) { + return error("Missing 'ids'"); + } + org.gephi.datalab.api.GraphElementsController gec = + Lookup.getDefault().lookup(org.gephi.datalab.api.GraphElementsController.class); + if (gec == null) { + return error("No datalab controller available"); + } + Graph g = gm.getGraph(); + java.util.List nodes = new java.util.ArrayList<>(); + for (String id : ids) { + Node n = g.getNode(id); + if (n == null) { + return error("Node not found: " + id); + } + nodes.add(n); + } + Node into = intoId != null ? g.getNode(intoId) : nodes.get(0); + if (into == null) { + return error("Merge target node not found: " + intoId); + } + try { + // Empty column/strategy arrays: reassign edges and keep the `into` node's + // own attribute values (no per-column value merge). Passing null throws + // an NPE inside the controller (it reads columns.length). + Node result = gec.mergeNodes(g, nodes.toArray(new Node[0]), into, + new Column[0], new org.gephi.datalab.spi.rows.merge.AttributeRowsMergeStrategy[0], true); + JsonObject r = success("Merged " + nodes.size() + " nodes"); + r.addProperty("into", result != null ? String.valueOf(result.getId()) : String.valueOf(into.getId())); + r.addProperty("merged_count", nodes.size()); + return r; + } catch (Exception e) { + return failure("Merge failed: ", e); + } + } + + // ─── Edge appearance + generic export (Group E) ────────────────── + + /** + * Color edges by an edge-column partition (relationship type, time period, + * weight tier, …) — the edge twin of colorByPartition: per-value palette (supplied or + * auto), applied through Gephi's Appearance API to the visible edges. + */ + public JsonObject colorEdgesByPartition(String columnName, Map colorMap) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + try { + GraphModel gm = currentGraphModel(); + Graph graph = gm.getGraph(); + Column col = findColumn(gm.getEdgeTable(), columnName); + if (col == null) { + return error("Edge column not found: " + columnName); + } + + java.util.Map palette = new java.util.LinkedHashMap<>(); + if (colorMap != null && !colorMap.isEmpty()) { + for (Map.Entry e : colorMap.entrySet()) { + int[] c = e.getValue(); + palette.put(e.getKey(), new Color(c[0], c[1], c[2])); + } + } else { + java.util.Map counts = new java.util.HashMap<>(); + for (Edge ed : graph.getEdges().toArray()) { + Object v = ed.getAttribute(col); + if (v != null) { + counts.merge(v.toString(), 1, Integer::sum); + } + } + palette.putAll(partitionPalette(counts)); + } + + org.gephi.appearance.api.Function f = applyAppearance(ws, col, true, + org.gephi.appearance.plugin.PartitionElementColorTransformer.class, + fn -> applyPaletteToPartition(((org.gephi.appearance.api.PartitionFunction) fn).getPartition(), + fn.getGraph(), palette)); + if (f == null) { + return noAppearanceFunction("edge partition colouring", col); + } + int colored = countVisible(gm, true, v -> v != null && palette.containsKey(v.toString()), col); + JsonObject r = success("Colored " + colored + " edges by " + columnName); + r.addProperty("partitions", palette.size()); + addPaletteNote(r, palette.size()); + addViewInfo(r, gm, true); + return r; + } catch (Exception e) { + return failure("Failed: ", e); + } + } + + /** + * Export the graph in any format the ExportController knows by name — vna, + * pajek, dl, spreadsheet, gdf, gml, json, gexf, graphml, csv — for + * interchange with UCINET and other SNA tools, or a spreadsheet for + * non-technical readers. The wrapped-today formats (gexf/graphml/csv) keep + * their dedicated tools; this is the passthrough for the rest. + */ + public JsonObject exportByFormat(String filePath, String format) { + return exportByFormat(filePath, format, true); + } + + /** + * @param visible see exportGexf — same contract, response self-declares the view. + */ + public JsonObject exportByFormat(String filePath, String format, boolean visible) { + Workspace ws = currentWorkspace(); + if (ws == null) { + return error("No project open"); + } + if (filePath == null || format == null) { + return error("Missing 'file' or 'format'"); + } + try { + ExportController ec = Lookup.getDefault().lookup(ExportController.class); + Exporter exporter = ec.getExporter(format); + if (exporter == null) { + return error("No exporter for format: " + format + + " (try vna, pajek, dl, spreadsheet, gdf, gml, json, gexf, graphml, csv)"); + } + if (exporter instanceof GraphExporter) { + ((GraphExporter) exporter).setExportVisible(visible); + ((GraphExporter) exporter).setWorkspace(ws); + } + ec.exportFile(new File(filePath), exporter); + JsonObject r = success("Exported to " + filePath); + r.addProperty("format", format); + addViewInfo(r, currentGraphModel(), visible); + return r; + } catch (Exception e) { + return failure("Export failed: ", e); + } + } + + // ─── Timeline / dynamic (Group G) ──────────────────────────────── + + /** + * Report the graph's dynamic/timeline state. Doubles as the spike for the + * reported "Timeline doesn't recognize dynamic attributes after a + * programmatic import" bug: if graph_is_dynamic is true but + * dynamic_columns is empty, the bug reproduces on this Gephi. + */ + public JsonObject getTimeline() { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + JsonObject r = success("Timeline state"); + try { + r.addProperty("graph_is_dynamic", gm.isDynamic()); + org.gephi.graph.api.Interval b = gm.getTimeBounds(); + if (b != null) { + r.addProperty("time_min", b.getLow()); + r.addProperty("time_max", b.getHigh()); + } + r.addProperty("time_format", String.valueOf(gm.getTimeFormat())); + } catch (Exception e) { + r.addProperty("bounds_error", e.getMessage()); + } + org.gephi.timeline.api.TimelineController tc = + Lookup.getDefault().lookup(org.gephi.timeline.api.TimelineController.class); + if (tc != null) { + try { + JsonArray cols = new JsonArray(); + String[] dc = tc.getDynamicGraphColumns(); + if (dc != null) { + for (String c : dc) { + cols.add(c); + } + } + r.add("dynamic_columns", cols); + org.gephi.timeline.api.TimelineModel tm = tc.getModel(); + if (tm != null) { + r.addProperty("timeline_enabled", tm.isEnabled()); + r.addProperty("has_valid_bounds", tm.hasValidBounds()); + if (tm.hasValidBounds()) { + r.addProperty("interval_start", tm.getIntervalStart()); + r.addProperty("interval_end", tm.getIntervalEnd()); + } + } + } catch (Exception e) { + r.addProperty("timeline_error", e.getMessage()); + } + } else { + r.addProperty("timeline_controller", "unavailable"); + } + return r; + } + + // REMOVED: setTimeWindow. Driving Gephi's timeline from outside wedges the + // EDT two different ways — a time-derived setVisibleView deadlocks the + // renderer, and even setInterval/setEnabled saturates the EDT after one call. + // Because Gephi's own shutdown runs on the EDT, a wedged timeline op makes + // the app impossible to quit normally (Force Quit only). getTimeline + // (read-only, above) is safe and kept; any future write path must go through + // the viz-engine render-pause and off the EDT before it can be revived. + + /** Create a boolean column flagging rows whose column value matches a regex. */ + public JsonObject createRegexColumn(String target, String columnId, String newColumnTitle, String regex) { + GraphModel gm = currentGraphModel(); + if (gm == null) { + return error("No workspace open"); + } + if (columnId == null || regex == null || newColumnTitle == null) { + return error("Missing 'column', 'regex', or 'new_column'"); + } + org.gephi.datalab.api.AttributeColumnsController acc = + Lookup.getDefault().lookup(org.gephi.datalab.api.AttributeColumnsController.class); + if (acc == null) { + return error("No datalab controller available"); + } + Table table = tableFor(gm, target); + Column col = findColumn(table, columnId); + if (col == null) { + return error("Column not found: " + columnId); + } + try { + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex); + Column created = acc.createBooleanMatchesColumn(table, col, newColumnTitle, pattern); + JsonObject r = success("Created boolean match column: " + newColumnTitle); + r.addProperty("column", created != null ? created.getId() : newColumnTitle); + return r; + } catch (java.util.regex.PatternSyntaxException e) { + return error("Invalid regex: " + e.getMessage()); + } catch (Exception e) { + return failure("Create match column failed: ", e); + } + } + +} diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/BindFailureNotifier.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/BindFailureNotifier.java new file mode 100644 index 000000000..5a9af241e --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/BindFailureNotifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.ui; + +import java.awt.GraphicsEnvironment; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.util.NbBundle; + +/** + * Shows server startup failures to the user in a dialog, so a port clash is + * never silent. Kept in the ui package so the module lifecycle classes stay + * free of user interface code. + */ +public final class BindFailureNotifier { + + private BindFailureNotifier() { + } + + /** + * Shows the message in an error dialog. Safe to call from any thread; + * DialogDisplayer.notifyLater queues the dialog for the event dispatch + * thread. + */ + public static void notifyStartupFailure(final String message) { + if (GraphicsEnvironment.isHeadless()) { + return; + } + NotifyDescriptor descriptor = + new NotifyDescriptor.Message(message, NotifyDescriptor.ERROR_MESSAGE); + descriptor.setTitle(NbBundle.getMessage(BindFailureNotifier.class, + "BindFailureNotifier.title")); + DialogDisplayer.getDefault().notifyLater(descriptor); + } +} diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlAction.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlAction.java new file mode 100644 index 000000000..8ac1ac2db --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlAction.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.ui; + +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; + +/** + * Tools menu entry that opens the Gephi AI server control dialog. The + * registration annotations generate the layer entries at compile time. + */ +@ActionID(category = "Tools", id = "org.gephi.plugins.mcp.ui.ServerControlAction") +@ActionRegistration(displayName = "#CTL_ServerControlAction") +@ActionReference(path = "Menu/Tools", position = 1550) +public final class ServerControlAction implements ActionListener { + + @Override + public void actionPerformed(ActionEvent e) { + ServerControlPanel.showDialog(); + } +} diff --git a/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlPanel.java b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlPanel.java new file mode 100644 index 000000000..37a196abb --- /dev/null +++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/ui/ServerControlPanel.java @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.ui; + +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ActionListener; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import org.gephi.plugins.mcp.Installer; +import org.openide.DialogDescriptor; +import org.openide.DialogDisplayer; +import org.openide.util.NbBundle; +import org.openide.util.RequestProcessor; + +/** + * Control panel for the Gephi AI server: shows whether the server is running + * and at which URL, lets the user start and stop it, and lets the user change + * the port. The port is persisted through Installer.setPreferredPort and takes + * effect on the next start. Start and stop run off the event dispatch thread. + */ +public final class ServerControlPanel extends JPanel { + + private static final RequestProcessor RP = new RequestProcessor("GephiAI-ServerControl", 1); + + static final int MIN_PORT = 1024; + static final int MAX_PORT = 65535; + + private final JLabel statusLabel = new JLabel(); + private final JTextField portField = new JTextField(6); + private final JButton startButton = new JButton(msg("ServerControlPanel.start")); + private final JButton stopButton = new JButton(msg("ServerControlPanel.stop")); + private final JLabel messageLabel = new JLabel(" "); + + ServerControlPanel() { + super(new GridBagLayout()); + portField.setText(Integer.toString(Installer.getPreferredPort())); + startButton.addActionListener(e -> onStart()); + stopButton.addActionListener(e -> onStop()); + buildLayout(); + refresh(); + } + + /** Opens the control dialog. Must be called on the event dispatch thread. */ + public static void showDialog() { + ServerControlPanel panel = new ServerControlPanel(); + String close = msg("ServerControlPanel.close"); + // The four argument constructor is used deliberately: the longer ones + // carry HelpCtx in their signatures, which lives in org-openide-util-ui, + // a module this plugin does not depend on. + DialogDescriptor descriptor = new DialogDescriptor( + panel, msg("ServerControlPanel.title"), true, (ActionListener) null); + descriptor.setOptions(new Object[]{close}); + DialogDisplayer.getDefault().notify(descriptor); + } + + private void buildLayout() { + GridBagConstraints c = new GridBagConstraints(); + c.anchor = GridBagConstraints.WEST; + c.insets = new Insets(4, 4, 4, 4); + + c.gridx = 0; + c.gridy = 0; + c.gridwidth = 4; + add(statusLabel, c); + + c.gridy = 1; + c.gridwidth = 1; + add(new JLabel(msg("ServerControlPanel.portLabel")), c); + c.gridx = 1; + add(portField, c); + c.gridx = 2; + add(startButton, c); + c.gridx = 3; + add(stopButton, c); + + c.gridx = 0; + c.gridy = 2; + c.gridwidth = 4; + add(new JLabel(msg("ServerControlPanel.portHint")), c); + + c.gridy = 3; + add(messageLabel, c); + } + + /** + * Parses a port field value. Returns the port when it is a usable number + * between MIN_PORT and MAX_PORT, and -1 otherwise. Static and free of + * Swing so it can be unit tested. + */ + static int parsePort(String text) { + if (text == null) { + return -1; + } + try { + int port = Integer.parseInt(text.trim()); + return (port >= MIN_PORT && port <= MAX_PORT) ? port : -1; + } catch (NumberFormatException e) { + return -1; + } + } + + private void onStart() { + int port = parsePort(portField.getText()); + if (port < 0) { + messageLabel.setText(msg("ServerControlPanel.portInvalid")); + return; + } + Installer.setPreferredPort(port); + setBusy(true); + RP.post(() -> { + final String error = Installer.requestStart(); + SwingUtilities.invokeLater(() -> { + messageLabel.setText(error == null ? " " : error); + setBusy(false); + refresh(); + }); + }); + } + + private void onStop() { + setBusy(true); + RP.post(() -> { + Installer.requestStop(); + SwingUtilities.invokeLater(() -> { + messageLabel.setText(" "); + setBusy(false); + refresh(); + }); + }); + } + + private void setBusy(boolean busy) { + startButton.setEnabled(!busy); + stopButton.setEnabled(!busy); + portField.setEnabled(!busy); + } + + private void refresh() { + if (Installer.isServerRunning()) { + String url = "http://127.0.0.1:" + Installer.getRunningPort(); + statusLabel.setText(NbBundle.getMessage(ServerControlPanel.class, + "ServerControlPanel.status.running", url)); + startButton.setEnabled(false); + stopButton.setEnabled(true); + } else { + statusLabel.setText(msg("ServerControlPanel.status.stopped")); + startButton.setEnabled(true); + stopButton.setEnabled(false); + } + } + + private static String msg(String key) { + return NbBundle.getMessage(ServerControlPanel.class, key); + } +} diff --git a/modules/GephiAI/src/main/nbm/manifest.mf b/modules/GephiAI/src/main/nbm/manifest.mf new file mode 100644 index 000000000..5b5387fd1 --- /dev/null +++ b/modules/GephiAI/src/main/nbm/manifest.mf @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Install: org/gephi/plugins/mcp/Installer.class +OpenIDE-Module-Localizing-Bundle: org/gephi/plugins/mcp/Bundle.properties diff --git a/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/Bundle.properties b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/Bundle.properties new file mode 100644 index 000000000..36a4aefbf --- /dev/null +++ b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/Bundle.properties @@ -0,0 +1,6 @@ +OpenIDE-Module-Name=Gephi AI +OpenIDE-Module-Display-Category=Tool +OpenIDE-Module-Short-Description=Control Gephi with AI assistants like Claude via the Model Context Protocol. +OpenIDE-Module-Long-Description=Lets AI assistants such as Claude work in Gephi with you: create projects, build and edit graphs, run layouts, compute statistics, and export results, all from a conversation. The plugin runs a local HTTP API that a Model Context Protocol server connects to. The API listens on 127.0.0.1 only, on port 8080 by default, and can be started, stopped, and moved to another port from Tools, Gephi AI Server.\n\nDeveloped by Matt Artz (https://www.mattartz.me | ORCID: https://orcid.org/0000-0002-3822-1429)\nSource: https://github.com/MattArtzAnthro/gephi-ai +Installer.error.bindFailed=The Gephi AI server could not start on port {0}: {1}. Another application may already be using that port. Choose a different port from Tools, Gephi AI Server, then start the server again. +Installer.error.moduleNotReady=The Gephi AI module has not finished loading yet. Try again in a moment. diff --git a/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/ui/Bundle.properties b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/ui/Bundle.properties new file mode 100644 index 000000000..ec00a65fb --- /dev/null +++ b/modules/GephiAI/src/main/resources/org/gephi/plugins/mcp/ui/Bundle.properties @@ -0,0 +1,11 @@ +CTL_ServerControlAction=Gephi AI Server... +BindFailureNotifier.title=Gephi AI +ServerControlPanel.title=Gephi AI Server +ServerControlPanel.status.running=Server running at {0} +ServerControlPanel.status.stopped=Server stopped +ServerControlPanel.portLabel=Port: +ServerControlPanel.start=Start +ServerControlPanel.stop=Stop +ServerControlPanel.close=Close +ServerControlPanel.portInvalid=Enter a port number between 1024 and 65535. +ServerControlPanel.portHint=Port changes take effect the next time the server starts. diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/InstallerLifecycleTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/InstallerLifecycleTest.java new file mode 100644 index 000000000..4bf082224 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/InstallerLifecycleTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Guards the startup and shutdown race: restored() delays the server start, and + * a module disable that lands inside that delay must cancel the start instead + * of leaving an unreachable server running. + */ +class InstallerLifecycleTest { + + @Test + void closeDuringStartupDelayCancelsTheStart() { + Installer installer = new Installer(); + // The module is disabled while the delayed start is still pending. + installer.close(); + // The delayed start arrives afterwards; it must not construct or bind a server. + assertNull(installer.startNow()); + assertFalse(installer.isRunningNow()); + } + + @Test + void closingDuringStartupDelayCancelsTheStart() { + Installer installer = new Installer(); + installer.closing(); + assertNull(installer.startNow()); + assertFalse(installer.isRunningNow()); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/SourceRulesTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/SourceRulesTest.java new file mode 100644 index 000000000..7d77128c6 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/SourceRulesTest.java @@ -0,0 +1,344 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Rules the plugin's source must keep, read from the source itself. Each guards against a way a + * Gephi plugin can freeze Gephi or slow its interface: a graph lock that is never released, a lock + * held by a loop over a live graph iterator, work the interface thread must not do, and threads + * that could keep Gephi from shutting down. + */ +class SourceRulesTest { + + private static final Path MAIN = Paths.get("src", "main", "java"); + + /** Methods whose job is to take a lock for the caller, who then releases it in a finally block. */ + private static final Set LOCK_HELPERS = Set.of("lockRead", "lockWrite"); + + /** The only places allowed to create threads, as "File#member". */ + private static final Set THREAD_OWNERS = Set.of( + "Installer#restored", + "Installer#stopNow", + "GephiAPIServer#startServer", + "GephiControlService#DEADLINES"); + + private static final Pattern LOCK_CALL = + Pattern.compile("^\\s*(?:(lockRead|lockWrite)\\(\\w+\\)|\\w+\\.(readLock|writeLock)\\(\\));"); + private static final Pattern UNLOCK = Pattern.compile("readUnlock\\(\\)|writeUnlock\\(\\)|unlockWrite\\("); + private static final Pattern LIVE_LOOP = Pattern.compile( + "for\\s*\\([^:]*:[^)]*\\.(getNodes|getEdges|getNeighbors|getOutEdges|getInEdges|getSelfLoops)\\("); + private static final Pattern EDT_CALL = Pattern.compile("\\b(runOnEDT|onEdt|onEdt\\.accept|invokeLater)\\("); + private static final Pattern NOT_ON_EDT = Pattern.compile( + "\\b(newProject|openProject|closeCurrentProject|newWorkspace|openWorkspace|deleteWorkspace" + + "|duplicateWorkspace|renameWorkspace|saveProject|cleanWorkspace)\\(" + + "|\\block(Read|Write)\\(|\\.(readLock|writeLock)\\(\\)|\\.get(Nodes|Edges)\\(\\)"); + private static final Pattern THREAD_START = Pattern.compile("new Thread\\(|Executors\\.new"); + private static final Pattern MEMBER = Pattern.compile( + "^ {4}(?!return |throw |if |for |while |else|new )(?:[\\w<>\\[\\],.?]+ )+(\\w+)\\s*(\\(|=|;)"); + + private static Map sources; + + @BeforeAll + static void readSources() throws IOException { + sources = new TreeMap<>(); + try (Stream files = Files.walk(MAIN)) { + for (Path p : files.filter(f -> f.toString().endsWith(".java")).collect(Collectors.toList())) { + sources.put(p.getFileName().toString(), Files.readString(p, StandardCharsets.UTF_8)); + } + } + } + + @Test + void everyGraphLockIsReleasedInAFinallyBlock() { + List found = new ArrayList<>(); + sources.forEach((file, text) -> found.addAll(unreleasedLocks(file, text))); + assertEquals(List.of(), found, "Take a graph lock, then open a try at once and release it in" + + " finally; anything between the two can throw and leave the lock held for good"); + } + + @Test + void noLoopRunsOverALiveGraphIterator() { + List found = new ArrayList<>(); + sources.forEach((file, text) -> { + String[] lines = text.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (!isComment(lines[i]) && LIVE_LOOP.matcher(lines[i]).find() && !lines[i].contains("toArray()")) { + found.add(file + ":" + (i + 1)); + } + } + }); + assertEquals(List.of(), found, "Loop over getNodes().toArray(): a live iterator holds the graph's" + + " read lock until it is exhausted, so leaving the loop early leaks the lock"); + } + + @Test + void caughtExceptionIsLoggedBeforeItIsReturned() { + Pattern catchAll = Pattern.compile("catch \\((Exception|RuntimeException|Throwable) \\w+\\) \\{"); + Pattern bareReturn = Pattern.compile("return error\\(.*\\+ \\w+\\.getMessage\\(\\)\\);"); + List found = new ArrayList<>(); + sources.forEach((file, text) -> { + String[] lines = text.split("\n", -1); + for (int i = 1; i < lines.length; i++) { + if (bareReturn.matcher(lines[i]).find() && catchAll.matcher(lines[i - 1]).find()) { + found.add(file + ":" + (i + 1)); + } + } + }); + assertEquals(List.of(), found, "Return failure(prefix, e), which logs the stack trace; a bare" + + " message leaves nothing in Gephi's log when the failure is inside Gephi"); + } + + @Test + void nothingWaitsOnTheInterfaceThread() { + List found = new ArrayList<>(); + sources.forEach((file, text) -> { + String[] lines = text.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (!isComment(lines[i]) && lines[i].contains("invokeAndWait")) { + found.add(file + ":" + (i + 1)); + } + } + }); + assertEquals(List.of(), found, "invokeAndWait blocks forever when the interface thread is busy;" + + " use runOnEDT, which waits with a limit"); + } + + @Test + void theInterfaceThreadNeitherChangesProjectsNorReadsTheGraph() { + List found = new ArrayList<>(); + sources.forEach((file, text) -> { + Matcher m = EDT_CALL.matcher(text); + while (m.find()) { + String body = parenthesised(text, m.end() - 1); + Matcher bad = NOT_ON_EDT.matcher(body); + if (bad.find()) { + found.add(file + ":" + lineOf(text, m.start()) + " " + bad.group()); + } + } + }); + assertEquals(List.of(), found, "Project and workspace changes and graph reads run on the calling" + + " thread, as Gephi's own interface runs them; only Swing work belongs on the interface thread"); + } + + @Test + void threadsAreCreatedOnlyInKnownPlacesAndNeverBlockShutdown() { + List found = new ArrayList<>(); + sources.forEach((file, text) -> { + String[] lines = text.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (isComment(lines[i]) || !THREAD_START.matcher(lines[i]).find()) { + continue; + } + int start = memberStart(lines, i); + String owner = file.replace(".java", "") + "#" + memberName(lines[start]); + String member = String.join("\n", List.of(lines).subList(start, memberEnd(lines, start))); + if (!THREAD_OWNERS.contains(owner)) { + found.add(owner + " creates a thread"); + } else if (!member.contains("setDaemon(true)")) { + found.add(owner + " creates a thread that is not a daemon"); + } else if (lines[i].contains("new Thread(") && !namedThread(member)) { + found.add(owner + " creates an unnamed thread"); + } + } + }); + assertEquals(List.of(), found, "Threads belong to a known owner, carry a name, and are daemons, so" + + " a thread dump says whose they are and Gephi can always quit"); + } + + @Test + void everySourceFileStartsWithTheLicenseHeader() throws IOException { + Pattern header = Pattern.compile("^/\\*\n \\* Copyright \\d{4} Matt Artz\n \\*\n" + + " \\* Licensed under the Apache License, Version 2\\.0 \\(the \"License\"\\);\n"); + List missing = new ArrayList<>(); + for (Path root : List.of(MAIN, Paths.get("src", "test", "java"))) { + try (Stream files = Files.walk(root)) { + for (Path p : files.filter(f -> f.toString().endsWith(".java")).collect(Collectors.toList())) { + if (!header.matcher(Files.readString(p, StandardCharsets.UTF_8)).find()) { + missing.add(p.toString()); + } + } + } + } + assertEquals(List.of(), missing, "Every Java file opens with the Apache 2.0 header the other files carry"); + } + + @Test + void theLockRuleAcceptsTryFinallyAndRejectsAGap() { + String good = "class A {\n void m(Graph g) {\n g.readLock();\n try {\n" + + " work();\n } finally {\n g.readUnlock();\n }\n }\n}\n"; + String gap = good.replace("g.readLock();\n", "g.readLock();\n prepare();\n"); + String noFinally = "class A {\n void m(Graph g) {\n g.readLock();\n try {\n" + + " work();\n } catch (Exception e) {\n" + + " g.readUnlock();\n }\n }\n}\n"; + assertEquals(List.of(), unreleasedLocks("A.java", good)); + assertEquals(List.of("A.java:3 is not followed at once by try"), unreleasedLocks("A.java", gap)); + assertEquals(List.of("A.java:3 is not released in the try's finally block"), + unreleasedLocks("A.java", noFinally)); + } + + @Test + void threadsNeedANameArgument() { + assertEquals(true, namedThread("Thread t = new Thread(r, \"Gephi AI worker\");")); + assertEquals(false, namedThread("Thread t = new Thread(r);")); + assertEquals(true, namedThread("Thread t = new Thread(() -> {\n run();\n}, \"Gephi AI starter\");")); + } + + // ---- rule mechanics, package-private so the rules themselves are tested above ---- + + static List unreleasedLocks(String file, String text) { + List found = new ArrayList<>(); + String[] lines = text.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (isComment(lines[i]) || !LOCK_CALL.matcher(lines[i]).find()) { + continue; + } + if (LOCK_HELPERS.contains(memberName(lines[memberStart(lines, i)]))) { + continue; + } + int next = i + 1; + while (next < lines.length && (lines[next].isBlank() || isComment(lines[next]))) { + next++; + } + if (next >= lines.length || !lines[next].trim().startsWith("try")) { + found.add(file + ":" + (i + 1) + " is not followed at once by try"); + continue; + } + String finallyBlock = finallyOf(text, offsetOf(lines, next)); + if (finallyBlock == null || !UNLOCK.matcher(finallyBlock).find()) { + found.add(file + ":" + (i + 1) + " is not released in the try's finally block"); + } + } + return found; + } + + /** The body of the finally block of the try statement starting at {@code at}, or null. */ + static String finallyOf(String text, int at) { + int i = text.indexOf('{', at); + while (i >= 0) { + int end = matching(text, i, '{', '}'); + int j = end + 1; + while (j < text.length() && Character.isWhitespace(text.charAt(j))) { + j++; + } + if (text.startsWith("finally", j)) { + int open = text.indexOf('{', j); + return text.substring(open, matching(text, open, '{', '}') + 1); + } + if (!text.startsWith("catch", j)) { + return null; + } + i = text.indexOf('{', j); + } + return null; + } + + /** True when every {@code new Thread(...)} in {@code member} passes a name as its last argument. */ + static boolean namedThread(String member) { + int at = member.indexOf("new Thread("); + while (at >= 0) { + String call = parenthesised(member, at + "new Thread".length()); + if (!call.matches("(?s).*,\\s*\"[^\"]+\"\\s*\\)")) { + return false; + } + at = member.indexOf("new Thread(", at + 1); + } + return true; + } + + private static boolean isComment(String line) { + String t = line.trim(); + return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*"); + } + + private static int memberStart(String[] lines, int i) { + for (int j = i; j >= 0; j--) { + if (MEMBER.matcher(lines[j]).find()) { + return j; + } + } + return 0; + } + + private static int memberEnd(String[] lines, int start) { + for (int j = start + 1; j < lines.length; j++) { + if (MEMBER.matcher(lines[j]).find()) { + return j; + } + } + return lines.length; + } + + private static String memberName(String line) { + Matcher m = MEMBER.matcher(line); + return m.find() ? m.group(1) : "?"; + } + + private static int offsetOf(String[] lines, int line) { + int offset = 0; + for (int i = 0; i < line; i++) { + offset += lines[i].length() + 1; + } + return offset; + } + + private static int lineOf(String text, int offset) { + return (int) text.substring(0, offset).chars().filter(c -> c == '\n').count() + 1; + } + + private static String parenthesised(String text, int open) { + return text.substring(open, matching(text, open, '(', ')') + 1); + } + + /** Index of the bracket closing the one at {@code open}, skipping string and character literals. */ + private static int matching(String text, int open, char left, char right) { + int depth = 0; + for (int i = open; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' || c == '\'') { + for (i++; i < text.length() && text.charAt(i) != c; i++) { + if (text.charAt(i) == '\\') { + i++; + } + } + } else if (c == left) { + depth++; + } else if (c == right && --depth == 0) { + return i; + } + } + return text.length() - 1; + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/TestEnvironmentTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/TestEnvironmentTest.java new file mode 100644 index 000000000..9bac5af13 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/TestEnvironmentTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.GraphicsEnvironment; +import org.junit.jupiter.api.Test; + +/** + * The module's installer runs inside the test JVM and starts the API server. Without these + * settings it binds 8080, and when a Gephi on the same machine already holds that port the + * failure opens an error dialog on the desktop in the middle of a test run. + */ +class TestEnvironmentTest { + + @Test + void testsRunHeadless() { + assertTrue(GraphicsEnvironment.isHeadless(), "tests must run with java.awt.headless=true"); + } + + @Test + void theServerStartedByTheInstallerStaysOffPort8080() { + assertEquals(0, Installer.getPreferredPort(), "tests must bind an ephemeral port, never 8080"); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiSmokeTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiSmokeTest.java new file mode 100644 index 000000000..c1ce118e1 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiSmokeTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * Boots the real server on an ephemeral port and exercises it over HTTP, so the + * seam that ships is the seam that is tested. Covers the /health liveness probe + * and the DELETE /workspace/delete contract, whose index is a query parameter + * (request bodies are parsed for POST and PUT only). + */ +class ApiSmokeTest { + + @Test + void healthAnswersAndWorkspaceDeleteRequiresTheIndexQueryParameter() throws Exception { + GephiAPIServer server = new GephiAPIServer(0); + server.startServer(); + try { + int port = server.getListeningPort(); + + HttpURLConnection health = open(port, "/health", "GET"); + assertEquals(200, health.getResponseCode()); + String healthBody = read(health.getInputStream()); + assertTrue(healthBody.contains("\"success\""), healthBody); + assertTrue(healthBody.contains("running"), healthBody); + + HttpURLConnection delete = open(port, "/workspace/delete", "DELETE"); + assertEquals(400, delete.getResponseCode()); + String deleteBody = read(delete.getErrorStream()); + assertTrue(deleteBody.contains("query parameter"), deleteBody); + + HttpURLConnection deleteWithParam = open(port, "/workspace/delete?index=abc", "DELETE"); + assertEquals(400, deleteWithParam.getResponseCode()); + String badIndexBody = read(deleteWithParam.getErrorStream()); + assertTrue(badIndexBody.contains("query parameter"), badIndexBody); + } finally { + // The server's own stop(); avoids shutting down the shared service + // singleton that other tests in the suite may still use. + server.stop(); + } + } + + private static HttpURLConnection open(int port, String path, String method) throws Exception { + URL url = new URL("http://127.0.0.1:" + port + path); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + return conn; + } + + private static String read(InputStream in) throws Exception { + if (in == null) { + return ""; + } + try (InputStream is = in) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = is.read(buf)) > 0) { + out.write(buf, 0, n); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/BrowserOriginTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/BrowserOriginTest.java new file mode 100644 index 000000000..388808d19 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/BrowserOriginTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Guards against a web page driving the API. + * + *

The Host-header check alone is not enough. A page the user is merely visiting can call + * {@code fetch("http://127.0.0.1:8080/graph/clear", {method:"POST", mode:"no-cors"})}: the + * browser sends {@code Host: 127.0.0.1:8080}, which the loopback check accepts, and a + * {@code text/plain} body is CORS-safelisted so no preflight is issued. CORS stops the page + * reading the reply, but the side effect has already happened, which is all an attacker needs + * to clear a workspace or write a file through an export endpoint. + * + *

Browsers attach {@code Origin} to such a request and {@code Sec-Fetch-Site} to every + * request, and page JavaScript cannot forge or suppress either. Non-browser clients (the MCP + * server, curl) send neither, so rejecting on them costs nothing. + */ +class BrowserOriginTest { + + @Test + void nonBrowserClientsAreAccepted() { + // No Origin, no Sec-Fetch-Site: the MCP server, curl, any local process. + assertTrue(GephiAPIServer.isNonBrowserRequest(null, null)); + assertTrue(GephiAPIServer.isNonBrowserRequest("", "")); + } + + @Test + void requestsCarryingAnOriginAreRejected() { + assertFalse(GephiAPIServer.isNonBrowserRequest("https://evil.example", null)); + assertFalse(GephiAPIServer.isNonBrowserRequest("http://localhost:3000", null)); + assertFalse(GephiAPIServer.isNonBrowserRequest("null", null)); + } + + @Test + void crossSiteAndSameOriginBrowserFetchesAreRejected() { + assertFalse(GephiAPIServer.isNonBrowserRequest(null, "cross-site")); + assertFalse(GephiAPIServer.isNonBrowserRequest(null, "same-site")); + assertFalse(GephiAPIServer.isNonBrowserRequest(null, "same-origin")); + } + + @Test + void userTypedNavigationIsStillRejectedWhenItCarriesAFetchMetadataHeader() { + // Sec-Fetch-Site: none means the user typed the URL or used a bookmark. That is a + // browser, and the API is not a browsing surface, so it is refused like any other. + assertFalse(GephiAPIServer.isNonBrowserRequest(null, "none")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java new file mode 100644 index 000000000..69a465a05 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HostHeaderTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Unit tests for the DNS-rebinding Host-header guard. */ +class HostHeaderTest { + + @Test + void loopbackHostsAreAccepted() { + assertTrue(GephiAPIServer.isLoopbackHost("127.0.0.1:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("127.0.0.1")); + assertTrue(GephiAPIServer.isLoopbackHost("localhost:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("localhost")); + assertTrue(GephiAPIServer.isLoopbackHost("LOCALHOST:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("[::1]:8080")); + assertTrue(GephiAPIServer.isLoopbackHost("[::1]")); + } + + @Test + void missingHostHeaderIsAllowed() { + // Non-browser clients (e.g. the MCP server) may omit Host; browsers never do, + // so this does not open a browser bypass. + assertTrue(GephiAPIServer.isLoopbackHost(null)); + assertTrue(GephiAPIServer.isLoopbackHost("")); + } + + @Test + void rebindingAndRemoteHostsAreRejected() { + assertFalse(GephiAPIServer.isLoopbackHost("evil.com")); + assertFalse(GephiAPIServer.isLoopbackHost("evil.com:8080")); + assertFalse(GephiAPIServer.isLoopbackHost("attacker.localhost.evil.com")); + assertFalse(GephiAPIServer.isLoopbackHost("127.0.0.1.evil.com")); + assertFalse(GephiAPIServer.isLoopbackHost("192.168.1.5:8080")); + assertFalse(GephiAPIServer.isLoopbackHost("0.0.0.0:8080")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HttpServerTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HttpServerTest.java new file mode 100644 index 000000000..8e0290ecc --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/HttpServerTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * The HTTP server over a real connection. Requests are written by hand on a socket, so a test + * can send what a browser or a rebinding page would send, including a Host header that Java's + * own HTTP client refuses to set. + */ +class HttpServerTest { + + private static GephiAPIServer server; + private static int port; + + @BeforeAll + static void start() throws Exception { + server = new GephiAPIServer(0); + server.startServer(); + port = server.getListeningPort(); + } + + @AfterAll + static void stop() { + server.stop(); + } + + /** Sends a raw request and returns the status code and the body. */ + private static String[] send(String request) throws Exception { + try (Socket s = new Socket("127.0.0.1", port)) { + s.setSoTimeout(5000); + OutputStream out = s.getOutputStream(); + out.write(request.getBytes(StandardCharsets.UTF_8)); + out.flush(); + InputStream in = s.getInputStream(); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + int n; + while ((n = in.read(b)) > 0) { + buf.write(b, 0, n); + } + String response = new String(buf.toByteArray(), StandardCharsets.UTF_8); + String status = response.split(" ", 3)[1]; + int split = response.indexOf("\r\n\r\n"); + return new String[]{status, split < 0 ? "" : response.substring(split + 4)}; + } + } + + private static String get(String path, String... headers) throws Exception { + StringBuilder r = new StringBuilder("GET " + path + " HTTP/1.1\r\nHost: 127.0.0.1\r\n"); + for (String h : headers) { + r.append(h).append("\r\n"); + } + return String.join("\n", send(r.append("Connection: close\r\n\r\n").toString())); + } + + @Test + void requestFromABrowserIsRefused() throws Exception { + assertTrue(get("/health", "Origin: https://example.com").startsWith("403\n")); + assertTrue(get("/health", "Sec-Fetch-Site: cross-site").startsWith("403\n")); + } + + @Test + void requestForAnotherHostIsRefused() throws Exception { + String[] r = send("GET /health HTTP/1.1\r\nHost: attacker.example:8080\r\nConnection: close\r\n\r\n"); + assertEquals("403", r[0]); + assertTrue(r[1].contains("localhost"), r[1]); + } + + @Test + void localRequestIsAnswered() throws Exception { + String r = get("/health"); + assertTrue(r.startsWith("200\n") && r.contains("running"), r); + } + + @Test + void healthReportsTheGephiVersion() throws Exception { + // The server compares it with the Gephi the newest plugin needs before advising an update. + String r = get("/health"); + assertTrue(r.matches("(?s).*\"gephi_version\": \"\\d+\\.\\d+(\\.\\d+)?\".*"), r); + } + + @Test + void optionsIsAnsweredWithNoBody() throws Exception { + String[] r = send("OPTIONS /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"); + assertEquals("200", r[0]); + assertEquals("", r[1]); + } + + @Test + void anUnknownEndpointIsAJsonError() throws Exception { + String r = get("/no/such/thing"); + assertTrue(r.startsWith("400\n") && r.contains("Unknown endpoint"), r); + } + + @Test + void malformedJsonIsAJsonErrorNotADroppedConnection() throws Exception { + String body = "{not json"; + String[] r = send("POST /graph/nodes/add HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n" + + "Content-Length: " + body.length() + "\r\nConnection: close\r\n\r\n" + body); + assertEquals("500", r[0]); + assertTrue(r[1].contains("\"success\": false"), r[1]); + } + + @Test + void chunkedBodyIsRead() throws Exception { + // Read as empty, this request would be refused as "Missing 'nodes' array". + String json = "{\"nodes\": []}"; + String[] r = send("POST /graph/nodes/add HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n" + + "Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + + Integer.toHexString(json.length()) + "\r\n" + json + "\r\n0\r\n\r\n"); + assertTrue(!r[1].contains("Missing") && !r[0].equals("500"), r[0] + " " + r[1]); + } + + @Test + void queryValuesAreDecoded() { + Map q = GephiAPIServer.parseQuery("name=Tom%C3%A1s+Soto&flag&&empty=&bad=%zz"); + assertEquals("Tomás Soto", q.get("name")); + assertEquals("", q.get("flag")); + assertEquals("", q.get("empty")); + assertEquals("%zz", q.get("bad")); + assertTrue(GephiAPIServer.parseQuery(null).isEmpty()); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/RequestBodyTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/RequestBodyTest.java new file mode 100644 index 000000000..d0708fa7f --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/RequestBodyTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * Request bodies are JSON, and JSON is UTF-8. NanoHTTPD's own parseBody decodes a body as + * US-ASCII when the Content-Type names no charset, which turned "Tomás" into "Tom��s" + * for every node, label, and attribute sent by a client that does not add one. + */ +class RequestBodyTest { + + private static String read(String json) throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + return GephiAPIServer.readBody(new ByteArrayInputStream(bytes), String.valueOf(bytes.length)); + } + + @Test + void nonAsciiTextSurvivesWithoutACharsetInTheContentType() throws IOException { + String json = "{\"id\":\"Tomás\",\"label\":\"Zoë Wójcik 東京\"}"; + assertEquals(json, read(json)); + } + + @Test + void theLengthIsCountedInBytesNotCharacters() throws IOException { + String json = "{\"id\":\"ąęółżźćń\"}"; + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + String extra = "trailing bytes that belong to no request"; + byte[] stream = (json + extra).getBytes(StandardCharsets.UTF_8); + assertEquals(json, GephiAPIServer.readBody(new ByteArrayInputStream(stream), String.valueOf(bytes.length))); + } + + @Test + void missingOrEmptyLengthReadsNothing() throws IOException { + assertEquals("", GephiAPIServer.readBody(new ByteArrayInputStream(new byte[0]), null)); + assertEquals("", GephiAPIServer.readBody(new ByteArrayInputStream(new byte[0]), " ")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/VisibleParamTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/VisibleParamTest.java new file mode 100644 index 000000000..bb513834e --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/VisibleParamTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * The {@code visible} switch that lets a caller choose between the full graph and the + * filtered view. The defaults matter more than the parsing: read endpoints have always + * returned the full graph and the export endpoints have always written the filtered one, + * and wiring this parameter must not change either, or every existing caller silently + * changes meaning. + */ +class VisibleParamTest { + + private static Map params(String key, String value) { + Map m = new HashMap<>(); + if (key != null) { + m.put(key, value); + } + return m; + } + + private static JsonObject body(String json) { + return JsonParser.parseString(json).getAsJsonObject(); + } + + @Test + void absentParameterKeepsTheEndpointsHistoricalView() { + assertFalse(GephiAPIServer.visibleParam(params(null, null), false), "reads default to the full graph"); + assertTrue(GephiAPIServer.visibleParam(params(null, null), true), "exports default to the visible graph"); + assertFalse(GephiAPIServer.visibleParam(null, false), "a null map must not throw"); + assertFalse(GephiAPIServer.visibleParam(params("visible", " "), false), "blank is treated as absent"); + } + + @Test + void queryParameterIsHonouredInBothDirections() { + assertTrue(GephiAPIServer.visibleParam(params("visible", "true"), false)); + assertTrue(GephiAPIServer.visibleParam(params("visible", "TRUE"), false)); + assertTrue(GephiAPIServer.visibleParam(params("visible", "1"), false)); + assertFalse(GephiAPIServer.visibleParam(params("visible", "false"), true)); + assertFalse(GephiAPIServer.visibleParam(params("visible", "0"), true)); + } + + @Test + void garbageFallsBackToTheDefaultRatherThanGuessing() { + assertFalse(GephiAPIServer.visibleParam(params("visible", "yes"), false)); + assertTrue(GephiAPIServer.visibleParam(params("visible", "banana"), true)); + } + + @Test + void bodyFlagIsHonouredAndDefaultsSafely() { + assertTrue(GephiAPIServer.visibleBody(body("{}"), true), "absent keeps the export default"); + assertFalse(GephiAPIServer.visibleBody(body("{}"), false)); + assertFalse(GephiAPIServer.visibleBody(body("{\"visible\":false}"), true)); + assertTrue(GephiAPIServer.visibleBody(body("{\"visible\":true}"), false)); + assertTrue(GephiAPIServer.visibleBody(body("{\"visible\":null}"), true), "explicit null is absent"); + assertTrue(GephiAPIServer.visibleBody(null, true), "a null body must not throw"); + } + + @Test + void nonBooleanBodyValueFallsBackRatherThanThrowing() { + assertTrue(GephiAPIServer.visibleBody(body("{\"visible\":{\"a\":1}}"), true)); + assertFalse(GephiAPIServer.visibleBody(body("{\"visible\":[1,2]}"), false)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/WelcomeCloserTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/WelcomeCloserTest.java new file mode 100644 index 000000000..f2a1da1e5 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/WelcomeCloserTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.api; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import org.junit.jupiter.api.Test; + +/** + * Gephi's Welcome window can stay open over the graph when work starts through the API right + * after launch. The plugin closes it on the first request that does work, but only in the first + * minutes after startup, so a Welcome the person reopens later from the Help menu is left alone. + */ +class WelcomeCloserTest { + + /** Stands in for Gephi's welcome panel, found by class name as the real one is. */ + static class FakeWelcomePanel extends JPanel { + } + + @Test + void theWelcomePanelIsFoundAnywhereInsideAWindow() { + JPanel outer = new JPanel(); + JPanel middle = new JPanel(); + middle.add(new FakeWelcomePanel()); + outer.add(new JLabel("header")); + outer.add(middle); + + assertTrue(WelcomeCloser.contains(outer, FakeWelcomePanel.class.getName())); + } + + @Test + void windowWithoutItIsLeftAlone() { + JPanel outer = new JPanel(); + outer.add(new JLabel("Screenshot saved")); + + assertFalse(WelcomeCloser.contains(outer, FakeWelcomePanel.class.getName())); + } + + @Test + void itTriesOnlyInTheFirstMinutesAndOnlyUntilItHasClosedOne() { + assertTrue(WelcomeCloser.shouldTry(60_000, false)); + assertFalse(WelcomeCloser.shouldTry(60_000, true)); + assertFalse(WelcomeCloser.shouldTry(WelcomeCloser.WINDOW_MS + 1, false)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/AppearancePanelTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/AppearancePanelTest.java new file mode 100644 index 000000000..a15af4862 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/AppearancePanelTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.gephi.appearance.api.Partition; +import org.gephi.appearance.plugin.RankingElementColorTransformer; +import org.gephi.appearance.plugin.RankingNodeSizeTransformer; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.Element; +import org.gephi.graph.api.Graph; +import org.junit.jupiter.api.Test; + +/** + * Gephi AI colours and sizes nodes itself, then sets Gephi's Appearance panel to the same + * choice, so the panel shows the column and the colours or sizes used and Apply there + * reproduces the result. These pin that the panel's settings match what was applied. + */ +class AppearancePanelTest { + + /** A partition over fixed values, recording the colour set for each. */ + static class FakePartition implements Partition { + final List values; + final Map colors = new HashMap<>(); + + FakePartition(Object... values) { + this.values = List.of(values); + } + + @Override public Collection getValues(Graph g) { + return values; + } + + @Override public Collection getSortedValues(Graph g) { + return values; + } + + @Override public int getElementCount(Graph g) { + return values.size(); + } + + @Override public int count(Object v, Graph g) { + return 1; + } + + @Override public Object getValue(Element e, Graph g) { + return null; + } + + @Override public Color getColor(Object v) { + return colors.get(v); + } + + @Override public void setColor(Object v, Color c) { + colors.put(v, c); + } + + @Override public void setColors(Graph g, Color[] c) { + } + + @Override public float percentage(Object v, Graph g) { + return 0; + } + + @Override public int size(Graph g) { + return values.size(); + } + + @Override public Column getColumn() { + return null; + } + } + + @Test + void thePartitionTakesTheExactColoursAppliedMatchedByValueText() { + FakePartition partition = new FakePartition(0, 1, 2); + Map palette = new LinkedHashMap<>(); + palette.put("0", new Color(42, 120, 214)); + palette.put("1", new Color(27, 175, 122)); + + int set = GephiControlService.applyPaletteToPartition(partition, null, palette); + + assertEquals(2, set); + assertEquals(new Color(42, 120, 214), partition.getColor(0)); + assertEquals(new Color(27, 175, 122), partition.getColor(1)); + assertNull(partition.getColor(2), "a value Gephi AI left alone keeps whatever the panel had"); + } + + @Test + void theRankingColourRunsFromTheMinimumToTheMaximumColour() { + RankingElementColorTransformer t = new RankingElementColorTransformer(); + + GephiControlService.configureRankingColor(t, new Color(240, 240, 240), new Color(20, 40, 160)); + + assertArrayEquals(new Color[]{new Color(240, 240, 240), new Color(20, 40, 160)}, t.getColors()); + assertArrayEquals(new float[]{0f, 1f}, t.getColorPositions()); + } + + @Test + void theRankingSizeRunsFromTheMinimumToTheMaximumSize() { + RankingNodeSizeTransformer t = new RankingNodeSizeTransformer(); + + GephiControlService.configureRankingSize(t, 3f, 25f); + + assertEquals(3f, t.getMinSize()); + assertEquals(25f, t.getMaxSize()); + } + + @Test + void columnIsFoundByItsIdOrByTheTitleUsersSee() { + org.gephi.graph.api.GraphModel gm = org.gephi.graph.api.GraphModel.Factory.newInstance(); + org.gephi.graph.api.Table nodes = gm.getNodeTable(); + nodes.addColumn("0", "group", String.class, org.gephi.graph.api.Origin.DATA, null, true); + + assertEquals("0", GephiControlService.findColumn(nodes, "0").getId()); + assertEquals("0", GephiControlService.findColumn(nodes, "group").getId()); + assertEquals("0", GephiControlService.findColumn(nodes, "Group").getId()); + assertNull(GephiControlService.findColumn(nodes, "department")); + assertNull(GephiControlService.findColumn(nodes, null)); + } + + @Test + void findingAColumnByTitleLeavesTheTableUsableByOtherThreads() throws Exception { + org.gephi.graph.api.GraphModel gm = org.gephi.graph.api.GraphModel.Factory.newInstance(); + org.gephi.graph.api.Table nodes = gm.getNodeTable(); + nodes.addColumn("0", "group", String.class, org.gephi.graph.api.Origin.DATA, null, true); + nodes.addColumn("1", "later", String.class, org.gephi.graph.api.Origin.DATA, null, true); + + GephiControlService.findColumn(nodes, "group"); + + // Iterating a table locks it until the loop ends. Returning from inside such a loop left + // the lock held, and the next thread to list the columns (Gephi's interface) waited forever. + java.util.concurrent.ExecutorService other = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + java.util.concurrent.Future count = other.submit(() -> { + int n = 0; + for (org.gephi.graph.api.Column c : nodes) { + n++; + } + return n; + }); + assertEquals(nodes.countColumns(), count.get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { + other.shutdownNow(); + } + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ColumnByTitleTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ColumnByTitleTest.java new file mode 100644 index 000000000..a40bcb561 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ColumnByTitleTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.gephi.graph.api.Table; +import org.junit.jupiter.api.Test; + +/** + * Node listings name columns by title, so a value written back under that title must land in + * the same column rather than a new one beside it. + */ +class ColumnByTitleTest { + + private static GraphModel withTitledColumn() { + GraphModel gm = GraphModel.Factory.newInstance(); + gm.getNodeTable().addColumn("modularity_class", "Modularity Class", Integer.class, + org.gephi.graph.api.Origin.DATA, null, true); + Node n = gm.factory().newNode("a"); + gm.getDirectedGraph().addNode(n); + return gm; + } + + @Test + void valueWrittenUnderAColumnsTitleLandsInThatColumn() { + GraphModel gm = withTitledColumn(); + Table table = gm.getNodeTable(); + Node n = gm.getGraph().getNode("a"); + int before = table.countColumns(); + + GephiControlService.ensureColumnAndSet(table, n, "Modularity Class", 4); + + assertEquals(before, table.countColumns(), "a duplicate column was created"); + assertEquals(4, n.getAttribute(table.getColumn("modularity_class"))); + } + + @Test + void anUnknownNameStillMakesANewColumn() { + GraphModel gm = withTitledColumn(); + Table table = gm.getNodeTable(); + Node n = gm.getGraph().getNode("a"); + assertNull(GephiControlService.findColumn(table, "Country")); + + GephiControlService.ensureColumnAndSet(table, n, "Country", "Peru"); + + Column c = GephiControlService.findColumn(table, "Country"); + assertEquals("Peru", n.getAttribute(c)); + } + + @Test + void valueCountsAcceptAColumnsTitle() { + GraphModel gm = withTitledColumn(); + gm.getGraph().getNode("a").setAttribute("modularity_class", 2); + + var r = GephiControlService.columnValueFrequenciesCore(gm, "nodes", "Modularity Class"); + + assertEquals(true, r.get("success").getAsBoolean(), r.toString()); + assertEquals(List.of("2"), List.copyOf(r.getAsJsonObject("frequencies").keySet())); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/DataToolsTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/DataToolsTest.java new file mode 100644 index 000000000..4b269a398 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/DataToolsTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.util.List; +import java.util.Map; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphController; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.gephi.project.api.ProjectController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openide.util.Lookup; + +/** Column tidy-up, time from columns, and combined filters on a small known network. */ +class DataToolsTest { + + private final GephiControlService service = GephiControlService.getInstance(); + private GraphModel gm; + + /** + * A path a-b-c-d plus e joined to a, b and c: degrees a2 b3 c3 d1 e3. Each node has a + * group (x for a, b, e; y for c, d), a score as text, and a joining year. + */ + @BeforeEach + void network() { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.closeCurrentProject(); + pc.newProject(); + gm = Lookup.getDefault().lookup(GraphController.class).getGraphModel(); + gm.getNodeTable().addColumn("group", String.class); + gm.getNodeTable().addColumn("score", String.class); + gm.getNodeTable().addColumn("joined", Integer.class); + Graph g = gm.getUndirectedGraph(); + Object[][] rows = {{"a", "x", "1.5", 1990}, {"b", "x", "oops", 1994}, {"c", "y", null, 1998}, + {"d", "y", "4", 1999}, {"e", "x", "2", 1991}}; + for (Object[] row : rows) { + Node n = gm.factory().newNode(row[0]); + n.setAttribute("group", row[1]); + if (row[2] != null) { + n.setAttribute("score", row[2]); + } + n.setAttribute("joined", row[3]); + g.addNode(n); + } + String[][] edges = {{"a", "b"}, {"b", "c"}, {"c", "d"}, {"e", "a"}, {"e", "b"}, {"e", "c"}}; + for (String[] e : edges) { + g.addEdge(gm.factory().newEdge(g.getNode(e[0]), g.getNode(e[1]), false)); + } + } + + private Object value(String node, String column) { + return gm.getGraph().getNode(node).getAttribute(GephiControlService.findColumn(gm.getNodeTable(), column)); + } + + @Test + void convertingReportsTheValuesItCouldNotRead() { + JsonObject r = service.editColumn("node", "score", "convert", null, "double", null); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertEquals(1, r.get("values_lost").getAsInt(), "'oops' is not a number"); + assertEquals(1.5, value("a", "score")); + assertNull(value("b", "score")); + } + + @Test + void fillingEmptyCellsLeavesFilledOnesAlone() { + JsonObject r = service.editColumn("node", "score", "fill_empty", "0", null, null); + + assertEquals(1, r.get("filled").getAsInt(), r.toString()); + assertEquals("0", value("c", "score")); + assertEquals("oops", value("b", "score")); + } + + @Test + void renamingKeepsTheValues() { + service.editColumn("node", "group", "rename", null, null, "Team"); + + assertNull(GephiControlService.findColumn(gm.getNodeTable(), "group")); + assertEquals("y", value("d", "Team")); + } + + @Test + void gephisOwnColumnsAreRefused() { + JsonObject r = service.editColumn("node", "Label", "delete", null, null, null); + + assertFalse(r.get("success").getAsBoolean()); + assertNotNull(GephiControlService.findColumn(gm.getNodeTable(), "Label")); + } + + @Test + void checkChangesNothing() { + JsonObject ok = service.editColumn("node", "group", "delete", null, null, null, true); + JsonObject refused = service.editColumn("node", "Label", "delete", null, null, null, true); + + assertTrue(ok.get("success").getAsBoolean()); + assertFalse(refused.get("success").getAsBoolean()); + assertNotNull(GephiControlService.findColumn(gm.getNodeTable(), "group"), "a check deleted the column"); + } + + @Test + void timeFromAYearColumnGivesEveryNodeAnInterval() { + JsonObject r = service.setTimeFromColumns("node", "joined", null, null); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertEquals(5, r.get("with_time").getAsInt()); + assertEquals(1990.0, r.get("time_min").getAsDouble()); + } + + @Test + void textDatesNeedAPatternAndAreCheckedBeforeAnythingChanges() { + JsonObject r = service.setTimeFromColumns("node", "group", null, null, true); + + assertFalse(r.get("success").getAsBoolean()); + assertTrue(r.get("error").getAsString().contains("date_format"), r.toString()); + } + + private int kept(JsonObject r) { + assertTrue(r.get("success").getAsBoolean(), r.toString()); + return r.get("nodes_kept").getAsInt(); + } + + @Test + void combinedFiltersCountAndOrAndNot() { + Map degree3 = Map.of("name", "Degree Range", "params", Map.of("range", List.of(3, 100))); + Map groupX = Map.of("name", "Equal: group String (Node)", "params", Map.of("pattern", "x")); + Map notX = Map.of("name", "Equal: group String (Node)", "params", Map.of("pattern", "x"), + "exclude", true); + + // degree 3: b, c, e. group x: a, b, e. + assertEquals(2, kept(service.applyFilters(List.of(degree3, groupX), "all", null, null, true))); + assertEquals(4, kept(service.applyFilters(List.of(degree3, groupX), "any", null, null, true))); + assertEquals(1, kept(service.applyFilters(List.of(degree3, notX), "all", null, null, true))); + assertEquals(5, gm.getGraphVisible().getNodeCount(), "a dry run hid nodes"); + } + + @Test + void anUnknownFilterIsNamed() { + JsonObject r = service.applyFilters(List.of(Map.of("name", "No Such Filter")), "all", null, null, true); + + assertFalse(r.get("success").getAsBoolean()); + assertTrue(r.get("error").getAsString().contains("No Such Filter")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/FailureLogTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/FailureLogTest.java new file mode 100644 index 000000000..8667ff7e5 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/FailureLogTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; + +/** + * A request that fails with an exception logs it with its stack trace. Before this, a crash inside + * Gephi (a null pointer in PNG export, for one) reached the caller as a one-line message and left + * nothing in Gephi's log to report. + */ +class FailureLogTest { + + @Test + void failureIsLoggedWithItsExceptionAndReturnedAsAnError() { + Logger logger = Logger.getLogger(GephiControlService.class.getName()); + List records = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord r) { + records.add(r); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(handler); + try { + NullPointerException npe = new NullPointerException("item is null"); + JsonObject r = GephiControlService.failure("Export failed: ", npe); + assertEquals(false, r.get("success").getAsBoolean()); + assertEquals("Export failed: item is null", r.get("error").getAsString()); + assertEquals(1, records.size()); + assertSame(npe, records.get(0).getThrown(), "the stack trace was not logged"); + } finally { + logger.removeHandler(handler); + } + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java new file mode 100644 index 000000000..3925b5de5 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GraphOpsTest.java @@ -0,0 +1,384 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for the graph-mutation cores against a standalone in-memory + * GraphModel (no NetBeans platform / running Gephi required). These exercise the + * actual fixes: batch attributes, edge directedness, the negative-value ranking + * regression, and CSV assembly. + */ +class GraphOpsTest { + + private static GraphModel newModel() { + return GraphModel.Factory.newInstance(); + } + + /** Build a node map {id, attributes:{...}} from id + alternating attr key/value pairs. */ + private static Map node(String id, Object... attrKv) { + Map m = new LinkedHashMap<>(); + m.put("id", id); + if (attrKv.length > 0) { + Map attrs = new LinkedHashMap<>(); + for (int i = 0; i + 1 < attrKv.length; i += 2) { + attrs.put((String) attrKv[i], attrKv[i + 1]); + } + m.put("attributes", attrs); + } + return m; + } + + @Test + void batchAddAppliesPerNodeAttributes() { + GraphModel gm = newModel(); + JsonObject r = GephiControlService.addNodesToModel(gm, + List.of(node("a", "team", "red"), node("b", "team", "blue"))); + assertTrue(r.get("success").getAsBoolean()); + assertEquals(2, r.get("added").getAsInt()); + + Graph g = gm.getGraph(); + Column team = gm.getNodeTable().getColumn("team"); + assertNotNull(team, "attribute column should be auto-created"); + assertEquals("red", g.getNode("a").getAttribute(team)); + assertEquals("blue", g.getNode("b").getAttribute(team)); + } + + @Test + void batchAddSkipsDuplicateIds() { + GraphModel gm = newModel(); + GephiControlService.addNodeToModel(gm, "a", null, null); + JsonObject r = GephiControlService.addNodesToModel(gm, List.of(node("a"), node("b"))); + assertEquals(1, r.get("added").getAsInt()); + assertEquals(1, r.get("skipped").getAsInt()); + } + + @Test + void addEdgeRespectsUndirectedFlag() { + GraphModel gm = newModel(); + GephiControlService.addNodeToModel(gm, "a", null, null); + GephiControlService.addNodeToModel(gm, "b", null, null); + JsonObject r = GephiControlService.addEdgeToModel(gm, "a", "b", 2.0, false); + assertTrue(r.get("success").getAsBoolean()); + + Graph g = gm.getGraph(); + Edge e = g.getEdge(g.getNode("a"), g.getNode("b"), 0); // type 0 == undirected + assertNotNull(e, "undirected edge (type 0) should exist"); + assertFalse(e.isDirected()); + assertEquals(2.0, e.getWeight(), 1e-9); + } + + @Test + void addEdgeRejectsDuplicate() { + GraphModel gm = newModel(); + GephiControlService.addNodeToModel(gm, "a", null, null); + GephiControlService.addNodeToModel(gm, "b", null, null); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true).get("success").getAsBoolean()); + assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true).get("success").getAsBoolean()); + } + + @Test + void batchAddEdgesHonorsDirectedLabelAndAttributes() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of(node("a"), node("b"))); + + Map edge = new LinkedHashMap<>(); + edge.put("source", "a"); + edge.put("target", "b"); + edge.put("directed", false); + edge.put("label", "knows"); + Map attrs = new LinkedHashMap<>(); + attrs.put("since", 1999); + edge.put("attributes", attrs); + + JsonObject r = GephiControlService.addEdgesToModel(gm, List.of(edge)); + assertEquals(1, r.get("added").getAsInt()); + + Graph g = gm.getGraph(); + Edge e = GephiControlService.findEdge(g, g.getNode("a"), g.getNode("b")); + assertNotNull(e); + assertFalse(e.isDirected()); + assertEquals("knows", e.getLabel()); + Column since = gm.getEdgeTable().getColumn("since"); + assertNotNull(since); + assertEquals(1999, ((Number) e.getAttribute(since)).intValue()); + } + + @Test + void numericRangeHandlesAllNegativeValues() { + // The regression that motivated the fix: a column whose values are all negative. + // The old Double.MIN_VALUE seed left max at a tiny positive number here. + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, + List.of(node("a", "score", -10.0), node("b", "score", -2.0), node("c", "score", -7.0))); + Column score = gm.getNodeTable().getColumn("score"); + double[] mm = GephiControlService.numericRange(gm.getGraph(), score); + assertNotNull(mm); + assertEquals(-10.0, mm[0], 1e-9, "min"); + assertEquals(-2.0, mm[1], 1e-9, "max"); + } + + @Test + void numericRangeIsNullWhenNoNumericValues() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of(node("a", "tag", "x"))); + Column tag = gm.getNodeTable().getColumn("tag"); + assertNull(GephiControlService.numericRange(gm.getGraph(), tag)); + } + + @Test + void addColumnCreatesAndRejectsDuplicateAndBadType() { + GraphModel gm = newModel(); + assertTrue(GephiControlService.addColumnToModel(gm, "weight2", "double", "node") + .get("success").getAsBoolean()); + assertNotNull(gm.getNodeTable().getColumn("weight2")); + // duplicate name -> error + assertFalse(GephiControlService.addColumnToModel(gm, "weight2", "double", "node") + .get("success").getAsBoolean()); + // unknown type -> error + assertFalse(GephiControlService.addColumnToModel(gm, "other", "notatype", "node") + .get("success").getAsBoolean()); + } + + // ── the deadlock-safe write lock (reflection linchpin) ────────────── + + @Test + void writeLockHandleResolvesGephiInternalLock() { + // If Gephi ever renames GraphLockImpl.writeLock, this returns null and lockWrite + // silently degrades to the deadlocking blocking lock. This test guards that. + GraphModel gm = newModel(); + assertNotNull(GephiControlService.writeLockHandle(gm.getGraph()), + "reflection into the graph's WriteLock must resolve"); + } + + @Test + void lockWriteAcquiresAndReleasesViaWriteUnlock() { + GraphModel gm = newModel(); + Graph g = gm.getGraph(); + GephiControlService.lockWrite(g); + try { + assertEquals(1, g.getLock().getWriteHoldCount(), "lockWrite must hold the write lock"); + } finally { + g.writeUnlock(); + } + assertEquals(0, g.getLock().getWriteHoldCount(), "writeUnlock must release what lockWrite took"); + } + + /** + * Regression guard for the wedge-by-leak bug: breaking out of a live + * auto-locked NodeIterable/EdgeIterable before exhaustion leaks a read hold + * that is never released (and, on a dying request thread, never releasable), + * after which no writer can ever acquire the lock. Query endpoints must + * iterate a toArray() snapshot instead. This encodes the graphstore contract + * both patterns rely on. + */ + @Test + void earlyBreakOverToArraySnapshotLeavesNoReadHold() throws Exception { + GraphModel gm = newModel(); + for (int i = 0; i < 10; i++) { + gm.getGraph().addNode(gm.factory().newNode("n" + i)); + } + Graph g = gm.getGraph(); + + // the fixed pattern: snapshot, then break early + int count = 0; + for (org.gephi.graph.api.Node n : g.getNodes().toArray()) { + if (count >= 3) { + break; + } + count++; + } + + java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock wl = + GephiControlService.writeLockHandle(g); + assertNotNull(wl, "write lock must be reachable via reflection"); + assertTrue(wl.tryLock(200, java.util.concurrent.TimeUnit.MILLISECONDS), + "write lock must be immediately acquirable after an early-broken toArray loop"); + wl.unlock(); + + // and the trap itself, for documentation: a live-iterable early break leaks + java.util.Iterator it = g.getNodes().iterator(); + it.next(); // iterator constructor auto-acquired the read lock + assertFalse(wl.tryLock(50, java.util.concurrent.TimeUnit.MILLISECONDS), + "an unexhausted live iterator holds the read lock (the leak this guards against)"); + while (it.hasNext()) { + it.next(); // exhaustion releases it + } + assertTrue(wl.tryLock(200, java.util.concurrent.TimeUnit.MILLISECONDS)); + wl.unlock(); + } + + @Test + void buildCsvQuotesFieldsContainingSeparator() { + GraphModel gm = newModel(); + Map n = new LinkedHashMap<>(); + n.put("id", "a"); + n.put("label", "Smith, John"); // label contains the separator -> must be quoted + GephiControlService.addNodesToModel(gm, List.of(n)); + + String[] lines = GephiControlService.buildCsv(gm, ",", "nodes").split("\n"); + assertEquals("Id,Label", lines[0]); + assertEquals("a,\"Smith, John\"", lines[1]); + } + + // ─── Data Laboratory cores (Group D) ───────────────────────────── + + @Test + void columnValueFrequenciesCountsPerValue() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of( + node("a", "team", "red"), node("b", "team", "red"), + node("c", "team", "blue"), node("d", "team", "red"))); + + JsonObject r = GephiControlService.columnValueFrequenciesCore(gm, "node", "team"); + assertTrue(r.get("success").getAsBoolean()); + assertEquals(2, r.get("distinct_values").getAsInt()); + assertEquals(4, r.get("total").getAsInt()); + JsonObject freq = r.getAsJsonObject("frequencies"); + assertEquals(3, freq.get("red").getAsInt()); + assertEquals(1, freq.get("blue").getAsInt()); + } + + @Test + void columnValueFrequenciesErrorsOnMissingColumn() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of(node("a"))); + JsonObject r = GephiControlService.columnValueFrequenciesCore(gm, "node", "nope"); + assertFalse(r.get("success").getAsBoolean()); + } + + @Test + void detectDuplicatesGroupsSharedValues() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of( + node("a", "email", "x@y.com"), node("b", "email", "x@y.com"), + node("c", "email", "z@y.com"), node("d", "email", "x@y.com"))); + + JsonObject r = GephiControlService.detectDuplicatesCore(gm, "node", "email", true); + assertTrue(r.get("success").getAsBoolean()); + assertEquals(1, r.get("group_count").getAsInt()); // only x@y.com is duplicated + assertEquals(3, r.getAsJsonArray("duplicate_groups").get(0).getAsJsonArray().size()); + } + + // ─── Typed parallel edges (Group F) ────────────────────────────── + + private static GraphModel modelWithNodes(String... ids) { + GraphModel gm = newModel(); + java.util.List> ns = new java.util.ArrayList<>(); + for (String id : ids) { + ns.add(node(id)); + } + GephiControlService.addNodesToModel(gm, ns); + return gm; + } + + @Test + void untypedDuplicateEdgeStillBlocked() { + // Regression: the pre-existing single-edge-per-pair rule must be unchanged + // when no edge_type is given. + GraphModel gm = modelWithNodes("a", "b"); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, null).get("success").getAsBoolean()); + assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, null).get("success").getAsBoolean()); + assertEquals(1, gm.getGraph().getEdgeCount()); + } + + @Test + void differentTypedEdgesCoexistBetweenSamePair() { + GraphModel gm = modelWithNodes("a", "b"); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean()); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, + "coauthor").get("success").getAsBoolean()); + assertEquals(2, gm.getGraph().getEdgeCount(), "two typed parallel edges should coexist"); + assertTrue(gm.getEdgeTypeCount() >= 2); + } + + @Test + void sameTypedEdgeIsStillBlocked() { + GraphModel gm = modelWithNodes("a", "b"); + assertTrue(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean()); + assertFalse(GephiControlService.addEdgeToModel(gm, "a", "b", 1.0, true, "cites").get("success").getAsBoolean(), + "a second edge of the SAME type between the same pair is still a duplicate"); + assertEquals(1, gm.getGraph().getEdgeCount()); + } + + @Test + void batchAddHonorsPerEdgeType() { + final GraphModel gm = modelWithNodes("a", "b"); + Map e1 = new LinkedHashMap<>(); + e1.put("source", "a"); + e1.put("target", "b"); + e1.put("edge_type", "cites"); + Map e2 = new LinkedHashMap<>(); + e2.put("source", "a"); + e2.put("target", "b"); + e2.put("edge_type", "coauthor"); + JsonObject r = GephiControlService.addEdgesToModel(gm, List.of(e1, e2)); + assertEquals(2, r.get("added").getAsInt()); + assertEquals(2, gm.getGraph().getEdgeCount()); + } + + @Test + void detectDuplicatesRespectsCaseInsensitivity() { + GraphModel gm = newModel(); + GephiControlService.addNodesToModel(gm, List.of( + node("a", "name", "Alice"), node("b", "name", "alice"))); + + assertEquals(0, GephiControlService.detectDuplicatesCore(gm, "node", "name", true) + .get("group_count").getAsInt()); // case-sensitive: distinct + assertEquals(1, GephiControlService.detectDuplicatesCore(gm, "node", "name", false) + .get("group_count").getAsInt()); // case-insensitive: same + } + + // ── empty-graph edge cases ─────────────────────────────────────────── + + @Test + void buildCsvOnEmptyGraphIsHeadersOnlyWithNoDataRows() { + GraphModel gm = newModel(); + String csv = GephiControlService.buildCsv(gm, ",", "both"); + String[] sections = csv.split("\n\n"); + assertEquals(2, sections.length, "expected a node section and an edge section"); + String[] nodeLines = sections[0].split("\n"); + assertEquals(1, nodeLines.length, "empty graph must yield the node header and zero rows"); + assertTrue(nodeLines[0].startsWith("Id,Label"), "node header missing: " + nodeLines[0]); + String[] edgeLines = sections[1].split("\n"); + assertEquals(1, edgeLines.length, "empty graph must yield the edge header and zero rows"); + assertTrue(edgeLines[0].startsWith("Source,Target,Weight"), "edge header missing: " + edgeLines[0]); + } + + @Test + void numericRangeOnEmptyGraphIsNull() { + GraphModel gm = newModel(); + Column score = gm.getNodeTable().addColumn("score", Double.class); + // No nodes at all (not merely no numeric values): must be null, not [∞, -∞]. + assertNull(GephiControlService.numericRange(gm.getGraph(), score)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java new file mode 100644 index 000000000..5f347ae5b --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/HelpersTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the pure helpers in GephiControlService — CSV quoting, type-string + * resolution, and value coercion. These need no Gephi runtime. + */ +class HelpersTest { + + // ── CSV (RFC 4180) escaping ────────────────────────────────────────── + + @Test + void csvLeavesPlainValuesUnquoted() { + assertEquals("hello", GephiControlService.csv("hello", ",")); + assertEquals("123", GephiControlService.csv("123", ",")); + } + + @Test + void csvQuotesValuesContainingSeparator() { + assertEquals("\"a,b\"", GephiControlService.csv("a,b", ",")); + } + + @Test + void csvDoublesInternalQuotes() { + assertEquals("\"she said \"\"hi\"\"\"", GephiControlService.csv("she said \"hi\"", ",")); + } + + @Test + void csvQuotesNewlines() { + assertEquals("\"line1\nline2\"", GephiControlService.csv("line1\nline2", ",")); + } + + @Test + void csvRespectsCustomSeparator() { + // a ';' is safe under a ',' separator but must be quoted under a ';' separator + assertEquals("a;b", GephiControlService.csv("a;b", ",")); + assertEquals("\"a;b\"", GephiControlService.csv("a;b", ";")); + } + + @Test + void csvHandlesNull() { + assertEquals("", GephiControlService.csv(null, ",")); + } + + // ── type string -> class ───────────────────────────────────────────── + + @Test + void typeStringToClassKnownTypes() { + assertEquals(String.class, GephiControlService.typeStringToClass("string")); + assertEquals(Integer.class, GephiControlService.typeStringToClass("INT")); + assertEquals(Integer.class, GephiControlService.typeStringToClass("integer")); + assertEquals(Double.class, GephiControlService.typeStringToClass("double")); + assertEquals(Boolean.class, GephiControlService.typeStringToClass("bool")); + assertEquals(Long.class, GephiControlService.typeStringToClass("long")); + } + + @Test + void typeStringToClassUnknownIsNull() { + assertNull(GephiControlService.typeStringToClass("nope")); + assertNull(GephiControlService.typeStringToClass(null)); + } + + // ── value coercion to a column's type ──────────────────────────────── + + @Test + void convertToColumnTypeParsesNumbers() { + assertEquals(7, GephiControlService.convertToColumnType("7.9", Integer.class)); // truncates + assertEquals(3.5, GephiControlService.convertToColumnType("3.5", Double.class)); + assertEquals(true, GephiControlService.convertToColumnType("true", Boolean.class)); + } + + @Test + void convertToColumnTypePassesThroughMatchingType() { + assertEquals(42, GephiControlService.convertToColumnType(42, Integer.class)); + } + + @Test + void convertToColumnTypeFallsBackToStringOnGarbage() { + assertEquals("abc", GephiControlService.convertToColumnType("abc", Integer.class)); + } + + // ── layout property coercion (e.g. "100.0" -> int 100) ─────────────── + + @Test + void convertLayoutPropertyHandlesNumericStrings() { + assertEquals(100, GephiControlService.convertLayoutProperty("100.0", int.class)); + assertEquals(2.5, GephiControlService.convertLayoutProperty("2.5", double.class)); + assertEquals(true, GephiControlService.convertLayoutProperty("true", boolean.class)); + assertEquals(1.5f, GephiControlService.convertLayoutProperty("1.5", float.class)); + } + + @Test + void convertLayoutPropertyReturnsNullOnGarbage() { + assertNull(GephiControlService.convertLayoutProperty("xyz", int.class)); + } + + // ── layout name matching (real Gephi builder names) ────────────────── + + private static final List LAYOUTS = List.of( + "Yifan Hu", "Yifan Hu Proportional", "Force Atlas", "ForceAtlas 2", + "Fruchterman Reingold", "Label Adjust", "Noverlap", "OpenOrd", "Random Layout"); + + @Test + void layoutMatchFoldsSpacesForDocumentedShortNames() { + // The names the skill/docs use must resolve to the real builders. + assertEquals("ForceAtlas 2", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "forceatlas2"))); + assertEquals("Yifan Hu", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "yifanhu"))); + assertEquals("Fruchterman Reingold", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "fruchterman"))); + } + + @Test + void layoutMatchPrefersExactOverSubstring() { + // "Force Atlas" must not be hijacked by "ForceAtlas 2" (and vice-versa). + assertEquals("Force Atlas", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "Force Atlas"))); + assertEquals("ForceAtlas 2", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "ForceAtlas 2"))); + } + + @Test + void layoutMatchReturnsMinusOneWhenNoMatch() { + assertEquals(-1, GephiControlService.bestLayoutMatch(LAYOUTS, "nonexistent")); + assertEquals(-1, GephiControlService.bestLayoutMatch(LAYOUTS, null)); + } + + @Test + void layoutMatchFallsBackToFirstSubstringMatch() { + // "atlas" matches no name exactly; the FIRST substring match ("Force Atlas", + // which precedes "ForceAtlas 2" in the registry order) must win. + assertEquals("Force Atlas", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "atlas"))); + // A single-name substring resolves to that name. + assertEquals("OpenOrd", LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "openo"))); + // Space folding applies to substring matching too: "chtermanrein" only matches + // "Fruchterman Reingold" once the space is folded out of the candidate name. + assertEquals("Fruchterman Reingold", + LAYOUTS.get(GephiControlService.bestLayoutMatch(LAYOUTS, "chtermanrein"))); + } + + @Test + void gephisHtmlFilterLabelsReadAsPlainText() { + assertEquals("group String (Node)", GephiControlService.plainText( + "group String (Node)")); + assertEquals("Degree Range", GephiControlService.plainText("Degree Range")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ImportRoundTripTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ImportRoundTripTest.java new file mode 100644 index 000000000..390806b42 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ImportRoundTripTest.java @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphController; +import org.gephi.graph.api.Node; +import org.gephi.project.api.ProjectController; +import org.gephi.project.api.Workspace; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openide.util.Lookup; + +/** + * Importing a GEXF or GraphML file keeps the positions, sizes and colors the file carries. + * + *

This runs Gephi's real import path in a plain JVM: the GEXF and GraphML importers, the import + * container, and the default processor, all found through Lookup. Gephi's containers + * auto-scale by default, which recenters every node and rescales sizes into 4 to 100, so + * a graph exported and imported again would come back with different positions and sizes. + * The import turns auto-scale off. + */ +class ImportRoundTripTest { + + /** id, x, y, size, r, g, b. Positions span thousands of units off-center; sizes 1 to 100. */ + private static final Object[][] NODES = { + {"a", 1036.5f, 257.25f, 18.44f, 230, 25, 75}, + {"b", 6200.0f, -3400.0f, 1.0f, 60, 180, 75}, + {"c", -2800.0f, 4100.0f, 100.0f, 0, 130, 200}, + {"d", 3500.0f, 9000.0f, 42.5f, 245, 130, 48}, + }; + + private static final float TOLERANCE = 1e-3f; + + @Test + void importKeepsTheFilePositionsSizesAndColors(@TempDir Path dir) throws Exception { + Path file = dir.resolve("styled.gexf"); + Files.write(file, gexf().getBytes(StandardCharsets.UTF_8)); + assertImportKeepsTheFileLayout(file); + } + + /** GraphML carries the same layout as node data keys, and must come back unchanged too. */ + @Test + void graphmlImportKeepsTheFilePositionsSizesAndColors(@TempDir Path dir) throws Exception { + Path file = dir.resolve("styled.graphml"); + Files.write(file, graphml().getBytes(StandardCharsets.UTF_8)); + assertImportKeepsTheFileLayout(file); + } + + private static void assertImportKeepsTheFileLayout(Path file) throws Exception { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + assertNotNull(pc, "a ProjectController must be registered for the import to run"); + pc.newProject(); + + JsonObject r = GephiControlService.getInstance().importFile(file.toString(), null); + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertEquals(NODES.length, r.get("node_count").getAsInt(), r.toString()); + assertEquals(1, r.get("edge_count").getAsInt(), r.toString()); + + Workspace ws = pc.getCurrentWorkspace(); + Graph g = Lookup.getDefault().lookup(GraphController.class).getGraphModel(ws).getGraph(); + StringBuilder mismatches = new StringBuilder(); + for (Object[] row : NODES) { + Node n = g.getNode((String) row[0]); + assertNotNull(n, "node " + row[0] + " must be imported"); + float x = (Float) row[1]; + float y = (Float) row[2]; + float size = (Float) row[3]; + if (Math.abs(n.x() - x) > TOLERANCE || Math.abs(n.y() - y) > TOLERANCE + || Math.abs(n.size() - size) > TOLERANCE) { + mismatches.append(String.format( + "%n %s: file (%.2f, %.2f) size %.2f, imported (%.2f, %.2f) size %.2f", + row[0], x, y, size, n.x(), n.y(), n.size())); + } + assertEquals((Integer) row[4], n.getColor().getRed(), "red of " + row[0]); + assertEquals((Integer) row[5], n.getColor().getGreen(), "green of " + row[0]); + assertEquals((Integer) row[6], n.getColor().getBlue(), "blue of " + row[0]); + } + assertTrue(mismatches.length() == 0, + "import changed the file's positions or sizes:" + mismatches); + } + + /** + * With auto-scale off, a file that carries no positions or sizes still imports as a + * usable graph: the container spreads the nodes at random and the processor gives + * each one the default size. + */ + @Test + void fileWithoutPositionsStillGetsSpreadOutNodes(@TempDir Path dir) throws Exception { + Path file = dir.resolve("bare.gexf"); + Files.write(file, ("\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n").getBytes(StandardCharsets.UTF_8)); + + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.newProject(); + JsonObject r = GephiControlService.getInstance().importFile(file.toString(), null); + assertTrue(r.get("success").getAsBoolean(), r.toString()); + + Graph g = Lookup.getDefault().lookup(GraphController.class) + .getGraphModel(pc.getCurrentWorkspace()).getGraph(); + assertEquals(3, g.getNodeCount()); + java.util.Set positions = new java.util.HashSet<>(); + for (Node n : g.getNodes()) { + assertTrue(n.x() != 0f || n.y() != 0f, "node " + n.getId() + " was left at the origin"); + assertEquals(10f, n.size(), TOLERANCE, "default size of " + n.getId()); + positions.add(n.x() + "," + n.y()); + } + assertEquals(3, positions.size(), "nodes must not share one position"); + } + + /** The keys Gephi's GraphML importer reads as position, size and color: x, y, size, r, g, b. */ + private static String graphml() { + StringBuilder s = new StringBuilder(); + s.append("\n") + .append("\n"); + for (String[] key : new String[][] { + {"x", "float"}, {"y", "float"}, {"size", "float"}, + {"r", "int"}, {"g", "int"}, {"b", "int"}}) { + s.append(String.format(java.util.Locale.ROOT, + " %n", + key[0], key[0], key[1])); + } + s.append(" \n"); + for (Object[] row : NODES) { + s.append(String.format(java.util.Locale.ROOT, + " %n" + + " %s%n" + + " %s%n" + + " %s%n" + + " %d%n" + + " %d%n" + + " %d%n" + + " %n", + row[0], row[1], row[2], row[3], row[4], row[5], row[6])); + } + s.append(" \n") + .append(" \n") + .append("\n"); + return s.toString(); + } + + private static String gexf() { + StringBuilder s = new StringBuilder(); + s.append("\n") + .append("\n") + .append(" \n") + .append(" \n"); + for (Object[] row : NODES) { + s.append(String.format(java.util.Locale.ROOT, + " %n" + + " %n" + + " %n" + + " %n" + + " %n", + row[0], row[0], row[3], row[1], row[2], row[4], row[5], row[6])); + } + s.append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append(" \n") + .append("\n"); + return s.toString(); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ImportWorkspaceTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ImportWorkspaceTest.java new file mode 100644 index 000000000..b8fc786f6 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ImportWorkspaceTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gephi.graph.api.GraphController; +import org.gephi.graph.api.GraphModel; +import org.gephi.project.api.Project; +import org.gephi.project.api.ProjectController; +import org.gephi.project.api.Workspace; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openide.util.Lookup; + +/** + * An import gets its own workspace, as in Gephi's own import dialog. A workspace's graph + * settings (time format, id type) are fixed when it is created, so importing into whatever + * workspace was open made timestamp and integer-id files fail. Adding to the current workspace + * is an explicit choice, and the file's import warnings are returned. + */ +class ImportWorkspaceTest { + + private static final String PLAIN = "\n" + + "" + + "" + + ""; + + private static ProjectController freshProject() { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.closeCurrentProject(); + pc.newProject(); + return pc; + } + + private static Path write(Path dir, String name, String body) throws Exception { + Path f = dir.resolve(name); + Files.writeString(f, body, StandardCharsets.UTF_8); + return f; + } + + private static GraphModel model(Workspace ws) { + return Lookup.getDefault().lookup(GraphController.class).getGraphModel(ws); + } + + private static int workspaceCount(ProjectController pc) { + Project p = pc.getCurrentProject(); + return p.getWorkspaces().size(); + } + + @Test + void fileWithTimestampsImports(@TempDir Path dir) throws Exception { + freshProject(); + Path f = write(dir, "timestamps.gexf", "\n" + + "" + + "" + + "" + + "" + + ""); + + JsonObject r = GephiControlService.getInstance().importFile(f.toString(), null); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertEquals(2, r.get("node_count").getAsInt()); + } + + @Test + void fileWithIntegerIdsImports(@TempDir Path dir) throws Exception { + freshProject(); + Path f = write(dir, "ints.gexf", "\n" + + "" + + "" + + "" + + ""); + + JsonObject r = GephiControlService.getInstance().importFile(f.toString(), null); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertEquals(2, r.get("node_count").getAsInt()); + } + + @Test + void anImportOpensItsOwnWorkspaceAndTidiesAwayAnEmptyOne(@TempDir Path dir) throws Exception { + ProjectController pc = freshProject(); + Workspace empty = pc.getCurrentWorkspace(); + + JsonObject r = GephiControlService.getInstance().importFile(write(dir, "plain.gexf", PLAIN).toString(), null); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertNotSame(empty, pc.getCurrentWorkspace()); + assertEquals(1, workspaceCount(pc), "the empty starting workspace should not be left behind"); + assertEquals("new_workspace", r.get("import_mode").getAsString()); + assertEquals("plain.gexf", pc.getCurrentWorkspace().getName(), "named after the file"); + } + + @Test + void anImportNeverReplacesAWorkspaceThatHasAGraph(@TempDir Path dir) throws Exception { + ProjectController pc = freshProject(); + GephiControlService.getInstance().importFile(write(dir, "first.gexf", PLAIN).toString(), null); + Workspace first = pc.getCurrentWorkspace(); + + GephiControlService.getInstance().importFile(write(dir, "second.gexf", PLAIN).toString(), null); + + assertNotSame(first, pc.getCurrentWorkspace()); + assertEquals(2, workspaceCount(pc)); + assertEquals(2, model(first).getGraph().getNodeCount(), "the first graph is untouched"); + } + + @Test + void appendAddsTheFileToTheCurrentWorkspace(@TempDir Path dir) throws Exception { + ProjectController pc = freshProject(); + GephiControlService.getInstance().importFile(write(dir, "first.gexf", PLAIN).toString(), null); + Workspace current = pc.getCurrentWorkspace(); + String more = PLAIN.replace("\"a\"", "\"c\"").replace("\"b\"", "\"d\"").replace("\"A\"", "\"C\"") + .replace("\"B\"", "\"D\"").replace("source=\"c\" target=\"d\"", "source=\"c\" target=\"d\""); + + JsonObject r = GephiControlService.getInstance() + .importFile(write(dir, "more.gexf", more).toString(), null, "append"); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertSame(current, pc.getCurrentWorkspace()); + assertEquals(4, model(current).getGraph().getNodeCount()); + assertEquals("append", r.get("import_mode").getAsString()); + } + + @Test + void theFilesImportWarningsAreReturned(@TempDir Path dir) throws Exception { + freshProject(); + Path f = write(dir, "dangling.gexf", PLAIN.replace("target=\"b\"", "target=\"missing\"")); + + JsonObject r = GephiControlService.getInstance().importFile(f.toString(), null); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertTrue(r.has("import_issues") && r.getAsJsonArray("import_issues").size() > 0, r.toString()); + assertFalse(r.getAsJsonArray("import_issues").get(0).getAsJsonObject().get("message").getAsString().isEmpty()); + } + + @Test + void appendingAFileWhoseTimeDoesNotFitIsRefusedAndChangesNothing(@TempDir Path dir) throws Exception { + ProjectController pc = freshProject(); + String stamps = "\n" + + "" + + ""; + String intervals = "\n" + + "" + + ""; + GephiControlService.getInstance().importFile(write(dir, "stamps.gexf", stamps).toString(), null); + Workspace current = pc.getCurrentWorkspace(); + + JsonObject r = GephiControlService.getInstance() + .importFile(write(dir, "intervals.gexf", intervals).toString(), null, "append"); + + assertFalse(r.get("success").getAsBoolean(), r.toString()); + assertTrue(r.get("error").getAsString().contains("its own workspace"), r.toString()); + assertSame(current, pc.getCurrentWorkspace()); + assertEquals(1, model(current).getGraph().getNodeCount(), "the refused file was partly added"); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/IsolatesTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/IsolatesTest.java new file mode 100644 index 000000000..09abb3a4b --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/IsolatesTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.stream.Collectors; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.junit.jupiter.api.Test; + +/** A dry run counts exactly the nodes a real run would remove: those with no ties at all. */ +class IsolatesTest { + + @Test + void onlyNodesWithNoTiesAreIsolates() { + GraphModel gm = GraphModel.Factory.newInstance(); + Graph g = gm.getGraph(); + Node a = gm.factory().newNode("a"); + Node b = gm.factory().newNode("b"); + Node alone = gm.factory().newNode("alone"); + Node loner = gm.factory().newNode("loner"); + g.addAllNodes(List.of(a, b, alone, loner)); + g.addEdge(gm.factory().newEdge(a, b, false)); + + List ids = GephiControlService.isolatedNodes(g).stream() + .map(n -> n.getId().toString()).sorted().collect(Collectors.toList()); + + assertEquals(List.of("alone", "loner"), ids); + assertEquals(4, g.getNodeCount(), "finding isolates must not remove them"); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutDefaultsTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutDefaultsTest.java new file mode 100644 index 000000000..42aab2fb5 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutDefaultsTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gephi.graph.api.GraphModel; +import org.gephi.layout.plugin.force.yifanHu.YifanHu; +import org.gephi.layout.plugin.forceAtlas2.ForceAtlas2Builder; +import org.gephi.layout.plugin.openord.OpenOrdLayoutBuilder; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutProperty; +import org.junit.jupiter.api.Test; + +/** + * Pins the premise behind the {@code findLayout()} reset. + * + *

A layout straight out of {@code buildLayout()} has its properties at Java zero-values; + * Gephi's real defaults are installed by {@code resetPropertiesValues()}, which the Gephi UI + * calls on selection. The MCP plugin never called it, so layouts ran on zeros: OpenOrd with + * {@code Layout Size} 0 collapsed every node onto (0,0), and Yifan Hu with + * {@code optimalDistance} 0 was a no-op that still reported success. + * + *

These tests construct the real Gephi layouts directly (no NetBeans platform) and assert + * both halves: that the zeros are really there before the reset, and that the reset clears + * them. If a future Gephi version starts self-initializing these layouts, the "before" + * assertions fail loudly rather than the fix quietly becoming redundant. + */ +class LayoutDefaultsTest { + + private static double numericProperty(Layout layout, String displayName) throws Exception { + for (LayoutProperty p : layout.getProperties()) { + if (displayName.equals(p.getProperty().getDisplayName())) { + Object v = p.getProperty().getValue(); + return v == null ? 0d : ((Number) v).doubleValue(); + } + } + throw new AssertionError("No such layout property: " + displayName); + } + + /** OpenOrd: Layout Size 0 is what collapsed every node onto the origin. */ + @Test + void openOrdStartsOnZerosAndResetFixesIt() throws Exception { + Layout layout = new OpenOrdLayoutBuilder().buildLayout(); + assertNotNull(layout); + layout.setGraphModel(GraphModel.Factory.newInstance()); + + assertEquals(0d, numericProperty(layout, "Layout Size"), 0d, + "expected an un-reset OpenOrd to report Layout Size 0"); + assertEquals(0d, numericProperty(layout, "Num Iterations"), 0d, + "expected an un-reset OpenOrd to report Num Iterations 0"); + + layout.resetPropertiesValues(); + + assertTrue(numericProperty(layout, "Layout Size") > 0d, + "reset must give OpenOrd a non-zero coordinate span, or the layout collapses"); + assertTrue(numericProperty(layout, "Num Iterations") > 0d, + "reset must give OpenOrd a non-zero iteration count"); + } + + /** Yifan Hu: optimalDistance/stepRatio 0 made the algorithm a silent no-op. */ + @Test + void yifanHuStartsOnZerosAndResetFixesIt() throws Exception { + Layout layout = new YifanHu().buildLayout(); + assertNotNull(layout); + layout.setGraphModel(GraphModel.Factory.newInstance()); + + assertEquals(0d, numericProperty(layout, "Optimal Distance"), 0d, + "expected an un-reset Yifan Hu to report Optimal Distance 0"); + assertEquals(0d, numericProperty(layout, "Step ratio"), 0d, + "expected an un-reset Yifan Hu to report Step ratio 0"); + + layout.resetPropertiesValues(); + + assertTrue(numericProperty(layout, "Optimal Distance") > 0d, + "reset must give Yifan Hu a non-zero optimal distance, or it does nothing"); + assertTrue(numericProperty(layout, "Step ratio") > 0d, + "reset must give Yifan Hu a non-zero step ratio"); + } + + /** + * ForceAtlas 2 was never affected — it is the control case, and the reason the bug went + * unnoticed: the workhorse layout self-initializes, so only the others ran on zeros. + */ + @Test + void forceAtlas2SelfInitializesBeforeAnyReset() throws Exception { + Layout layout = new ForceAtlas2Builder().buildLayout(); + assertNotNull(layout); + layout.setGraphModel(GraphModel.Factory.newInstance()); + + assertTrue(numericProperty(layout, "Scaling") > 0d, + "ForceAtlas 2 is expected to arrive already initialized"); + assertTrue(numericProperty(layout, "Tolerance (speed)") > 0d, + "ForceAtlas 2 is expected to arrive already initialized"); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutPropertyNamesTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutPropertyNamesTest.java new file mode 100644 index 000000000..e67ca8a3e --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LayoutPropertyNamesTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.gephi.layout.plugin.forceAtlas2.ForceAtlas2Builder; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutProperty; +import org.junit.jupiter.api.Test; + +/** + * A layout setting whose name matches no property used to be dropped without a word, so a + * misspelled key ran the layout on its defaults. Every name that matched nothing is reported. + */ +class LayoutPropertyNamesTest { + + private static Object value(Layout layout, String key) throws Exception { + for (LayoutProperty p : layout.getProperties()) { + if (p.getCanonicalName() != null && p.getCanonicalName().contains("." + key + ".")) { + return p.getProperty().getValue(); + } + } + throw new AssertionError("No such property: " + key); + } + + @Test + void namesThatMatchNothingAreReportedAndTheRestApplied() throws Exception { + Layout layout = new ForceAtlas2Builder().buildLayout(); + Map props = new LinkedHashMap<>(); + props.put("linLogMode", true); + props.put("barnesHutOptimize", true); + props.put("preventOverlap", true); + props.put("strongGravityMode", true); + + List unapplied = GephiControlService.applyLayoutProperties(layout, props); + + assertEquals(List.of("barnesHutOptimize", "preventOverlap"), unapplied); + assertEquals(true, value(layout, "linLogMode")); + assertEquals(true, value(layout, "strongGravityMode")); + } + + @Test + void displayNamesAndOtherCasesStillCountAsMatches() throws Exception { + Layout layout = new ForceAtlas2Builder().buildLayout(); + Map props = new LinkedHashMap<>(); + props.put("LinLog mode", true); + props.put("SCALINGRATIO", 0.5); + + List unapplied = GephiControlService.applyLayoutProperties(layout, props); + + assertTrue(unapplied.isEmpty(), "unexpected: " + unapplied); + assertEquals(true, value(layout, "linLogMode")); + assertEquals(0.5, ((Number) value(layout, "scalingRatio")).doubleValue(), 1e-9); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LockContentionTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LockContentionTest.java new file mode 100644 index 000000000..de559dc24 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/LockContentionTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.junit.jupiter.api.Test; + +/** + * Contention tests for the deadlock-safe lock helpers, against the real + * ReentrantReadWriteLock behind a standalone GraphModel (two threads, no running + * Gephi). lockWrite and lockRead poll a timed tryLock instead of parking in the + * lock's wait queue (see the comments on those helpers); what matters under + * contention is that they wait through a held lock and then genuinely acquire, + * rather than failing fast, wedging forever, or reporting a hold they do not have. + * + *

Contention is established by a latch the test itself controls, never by a + * sleep. The holder keeps the lock until this test releases it, so "the contender + * is still blocked" is a fact about the lock rather than a guess about scheduling. + * The only wall-clock value here is the probe below, and it can fail in one + * direction only: if the contender acquires while the holder provably still holds, + * which is the defect these tests exist to catch. + */ +class LockContentionTest { + + /** How long to watch a contender that must not succeed yet. */ + private static final long BLOCKED_PROBE_MS = 300; + + /** Generous ceiling for an acquisition that should follow release almost at once. */ + private static final long ACQUIRE_TIMEOUT_S = 15; + + private static Graph newGraph() { + return GraphModel.Factory.newInstance().getGraph(); + } + + @Test + void lockWriteWaitsOutAContendingReaderAndActuallyAcquires() throws Exception { + Graph g = newGraph(); + ReentrantReadWriteLock.ReadLock rl = GephiControlService.readLockHandle(g); + assertNotNull(rl, "read lock handle must be reachable (lockWrite depends on it)"); + + CountDownLatch readerHolds = new CountDownLatch(1); + CountDownLatch releaseReader = new CountDownLatch(1); + Thread reader = new Thread(() -> { + rl.lock(); + try { + readerHolds.countDown(); + releaseReader.await(30, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } finally { + rl.unlock(); + } + }, "contending-reader"); + reader.setDaemon(true); + reader.start(); + assertTrue(readerHolds.await(5, TimeUnit.SECONDS), "reader thread failed to start"); + + // lockWrite runs on its own thread because a write hold count is per-thread, + // and because the main thread must stay free to release the reader. + CountDownLatch acquired = new CountDownLatch(1); + AtomicInteger holdCountWhileHeld = new AtomicInteger(-1); + AtomicReference failure = new AtomicReference<>(); + Thread writer = new Thread(() -> { + try { + GephiControlService.lockWrite(g); + try { + holdCountWhileHeld.set(g.getLock().getWriteHoldCount()); + } finally { + GephiControlService.unlockWrite(g); + } + } catch (Throwable t) { + failure.set(t); + } finally { + acquired.countDown(); + } + }, "contending-writer"); + writer.setDaemon(true); + writer.start(); + + // The reader still holds, and only this thread can release it, so a writer + // that finishes here acquired a write lock over a live read hold. + assertFalse(acquired.await(BLOCKED_PROBE_MS, TimeUnit.MILLISECONDS), + "lockWrite acquired while a reader still held the lock"); + + releaseReader.countDown(); + + assertTrue(acquired.await(ACQUIRE_TIMEOUT_S, TimeUnit.SECONDS), + "lockWrite never acquired after the reader released"); + assertNull(failure.get(), () -> "lockWrite threw: " + failure.get()); + assertEquals(1, holdCountWhileHeld.get(), + "lockWrite returned without actually holding the write lock"); + + reader.join(5_000); + writer.join(5_000); + assertFalse(reader.isAlive(), "reader thread leaked"); + assertFalse(writer.isAlive(), "writer thread leaked"); + } + + @Test + void lockReadWaitsOutAHeldWriterAndActuallyAcquires() throws Exception { + Graph g = newGraph(); + ReentrantReadWriteLock.WriteLock wl = GephiControlService.writeLockHandle(g); + assertNotNull(wl, "write lock handle must be reachable (lockRead's counterpart)"); + + CountDownLatch writerHolds = new CountDownLatch(1); + CountDownLatch releaseWriter = new CountDownLatch(1); + Thread writer = new Thread(() -> { + wl.lock(); + try { + writerHolds.countDown(); + releaseWriter.await(30, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } finally { + wl.unlock(); + } + }, "holding-writer"); + writer.setDaemon(true); + writer.start(); + assertTrue(writerHolds.await(5, TimeUnit.SECONDS), "writer thread failed to start"); + + CountDownLatch acquired = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread readerThread = new Thread(() -> { + try { + GephiControlService.lockRead(g); + g.readUnlock(); + } catch (Throwable t) { + failure.set(t); + } finally { + acquired.countDown(); + } + }, "contending-reader"); + readerThread.setDaemon(true); + readerThread.start(); + + assertFalse(acquired.await(BLOCKED_PROBE_MS, TimeUnit.MILLISECONDS), + "lockRead acquired while a writer still held the lock"); + + releaseWriter.countDown(); + + assertTrue(acquired.await(ACQUIRE_TIMEOUT_S, TimeUnit.SECONDS), + "lockRead never acquired after the writer released"); + assertNull(failure.get(), () -> "lockRead threw: " + failure.get()); + + writer.join(5_000); + readerThread.join(5_000); + assertFalse(writer.isAlive(), "writer thread leaked"); + assertFalse(readerThread.isAlive(), "reader thread leaked"); + } + + /** + * A long reader (a running statistic, in Gephi) must not let a waiting plugin write freeze + * every other reader, and the write must give up with an error that does not blame the + * renderer. A blocking writeLock() here parks at the head of the queue and new readers + * (the renderer, layouts, the interface thread) wait behind it until the long reader ends. + */ + @Test + void lockWriteGivesUpBehindALongReaderWithoutBlockingOtherReaders() throws Exception { + Graph g = newGraph(); + ReentrantReadWriteLock.ReadLock rl = GephiControlService.readLockHandle(g); + assertNotNull(rl, "read lock handle must be reachable (lockWrite depends on it)"); + + CountDownLatch readerHolds = new CountDownLatch(1); + CountDownLatch releaseReader = new CountDownLatch(1); + Thread longReader = new Thread(() -> { + rl.lock(); + try { + readerHolds.countDown(); + releaseReader.await(60, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } finally { + rl.unlock(); + } + }, "long-reader"); + longReader.setDaemon(true); + longReader.start(); + assertTrue(readerHolds.await(5, TimeUnit.SECONDS), "reader thread failed to start"); + + AtomicReference failure = new AtomicReference<>(); + CountDownLatch writerDone = new CountDownLatch(1); + Thread writer = new Thread(() -> { + try { + GephiControlService.lockWrite(g); + GephiControlService.unlockWrite(g); + } catch (Throwable t) { + failure.set(t); + } finally { + writerDone.countDown(); + } + }, "waiting-writer"); + writer.setDaemon(true); + writer.start(); + + try { + // While the write waits, fresh readers must still get in, as the renderer does + // every frame. Each gets well under a second; a parked writer would hold them + // until the long reader ended. + for (int i = 0; i < 5; i++) { + Thread.sleep(500); + CountDownLatch got = new CountDownLatch(1); + Thread probe = new Thread(() -> { + rl.lock(); + rl.unlock(); + got.countDown(); + }, "probe-reader"); + probe.setDaemon(true); + probe.start(); + assertTrue(got.await(1, TimeUnit.SECONDS), + "a new reader waited behind the pending write"); + } + + assertTrue(writerDone.await(30, TimeUnit.SECONDS), "lockWrite never gave up"); + assertNotNull(failure.get(), "lockWrite acquired over a live read hold"); + String message = String.valueOf(failure.get().getMessage()); + assertTrue(message.contains("Graph is busy"), message); + assertFalse(message.contains("renderer"), "the busy message blames the renderer: " + message); + } finally { + releaseReader.countDown(); + longReader.join(5_000); + writer.join(5_000); + } + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/NodeSearchTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/NodeSearchTest.java new file mode 100644 index 000000000..85f42425e --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/NodeSearchTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.junit.jupiter.api.Test; + +/** Finding nodes by the value in one column. */ +class NodeSearchTest { + + private static GraphModel people() { + GraphModel gm = GraphModel.Factory.newInstance(); + gm.getNodeTable().addColumn("country", String.class); + gm.getNodeTable().addColumn("score", Double.class); + Object[][] rows = {{"a", "Peru", 0.5}, {"b", "peru", 2.0}, {"c", "Portugal", 7.5}, {"d", null, 1.0}}; + for (Object[] row : rows) { + Node n = gm.factory().newNode((String) row[0]); + if (row[1] != null) { + n.setAttribute("country", row[1]); + } + n.setAttribute("score", row[2]); + gm.getDirectedGraph().addNode(n); + } + return gm; + } + + private static List find(GraphModel gm, String column, String value, String contains, + Double min, Double max) { + Column col = GephiControlService.findColumn(gm.getNodeTable(), column); + Predicate keep = GephiControlService.nodeMatcher(col, value, contains, min, max); + return gm.getGraph().getNodes().toCollection().stream().filter(keep) + .map(n -> (String) n.getId()).sorted().collect(Collectors.toList()); + } + + @Test + void wholeValueMatchesIgnoringCase() { + assertEquals(List.of("a", "b"), find(people(), "country", "PERU", null, null, null)); + } + + @Test + void partOfTheTextMatches() { + assertEquals(List.of("a", "b", "c"), find(people(), "country", null, "p", null, null)); + } + + @Test + void numericRangeIsInclusive() { + assertEquals(List.of("b", "d"), find(people(), "score", null, null, 1.0, 2.0)); + } + + @Test + void numberMatchesByValueNotByText() { + assertEquals(List.of("b"), find(people(), "score", "2", null, null, null)); + } + + @Test + void noSearchMeansNoFilter() { + assertNull(GephiControlService.nodeMatcher(null, null, null, null, null)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/NodeSortTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/NodeSortTest.java new file mode 100644 index 000000000..7d7d34bd8 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/NodeSortTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** + * "Who are the top ten?" is the commonest question asked of a network. Nodes are sorted by a + * column before paging, so the first page is the top of the whole graph, not of the first ids. + */ +class NodeSortTest { + + private static final Map VALUES = new HashMap<>(); + + static { + VALUES.put("a", 3.0); + VALUES.put("b", 10); + VALUES.put("c", null); + VALUES.put("d", 7.5); + } + + @Test + void largestFirstWhenDescending() { + List sorted = GephiControlService.sortByValue(List.of("a", "b", "c", "d"), VALUES::get, true); + assertEquals(List.of("b", "d", "a", "c"), sorted); + } + + @Test + void smallestFirstWhenAscendingWithMissingValuesStillLast() { + List sorted = GephiControlService.sortByValue(List.of("a", "b", "c", "d"), VALUES::get, false); + assertEquals(List.of("a", "d", "b", "c"), sorted); + } + + @Test + void textSortsAlphabeticallyIgnoringCase() { + Map names = Map.of("x", "banana", "y", "Apple", "z", "cherry"); + List sorted = GephiControlService.sortByValue(List.of("x", "y", "z"), names::get, false); + assertEquals(List.of("y", "x", "z"), sorted); + } + + @Test + void requestedColumnIsKeptByIdOrByTitleIgnoringCase() { + Set wanted = GephiControlService.wantedColumns("pageranks, Betweenness Centrality"); + assertTrue(GephiControlService.isWanted(wanted, "pageranks", "PageRank")); + assertTrue(GephiControlService.isWanted(wanted, "betweenesscentrality", "Betweenness Centrality")); + assertFalse(GephiControlService.isWanted(wanted, "eccentricity", "Eccentricity")); + } + + @Test + void noRequestedColumnsKeepsEveryColumn() { + Set wanted = GephiControlService.wantedColumns(null); + assertTrue(GephiControlService.isWanted(wanted, "eccentricity", "Eccentricity")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PanelExecutionTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PanelExecutionTest.java new file mode 100644 index 000000000..a257ecdf4 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PanelExecutionTest.java @@ -0,0 +1,400 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import org.gephi.desktop.statistics.api.StatisticsControllerUI; +import org.gephi.graph.api.GraphModel; +import org.gephi.layout.api.LayoutController; +import org.gephi.layout.api.LayoutModel; +import org.gephi.layout.plugin.forceAtlas2.ForceAtlas2Builder; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutProperty; +import org.gephi.project.api.Workspace; +import org.gephi.statistics.spi.Statistics; +import org.gephi.statistics.spi.StatisticsUI; +import org.gephi.utils.longtask.api.LongTaskListener; +import org.gephi.utils.longtask.spi.LongTask; +import org.gephi.utils.progress.ProgressTicket; +import org.junit.jupiter.api.Test; + +/** + * Statistics and layouts run through Gephi's own controllers so its Statistics and Layout panels + * show what Gephi AI is doing. These stand-in controllers pin the waiting, stopping, error and + * call-order logic without the desktop interface. + */ +class PanelExecutionTest { + + private static final Consumer DIRECT = Runnable::run; + + /** A statistic that runs until cancelled, or for a fixed time. */ + static class SlowStat implements Statistics, LongTask { + final long millis; + final AtomicBoolean cancelled = new AtomicBoolean(); + final AtomicBoolean ran = new AtomicBoolean(); + + SlowStat(long millis) { + this.millis = millis; + } + + @Override public void execute(GraphModel gm) { + ran.set(true); + long end = System.currentTimeMillis() + millis; + while (!cancelled.get() && System.currentTimeMillis() < end) { + Thread.onSpinWait(); + } + } + + @Override public String getReport() { + return ""; + } + + @Override public boolean cancel() { + cancelled.set(true); + return true; + } + + @Override public void setProgressTicket(ProgressTicket t) { + } + } + + /** Runs the statistic on its own thread and reports back, as Gephi's panel controller does. */ + static class FakeStatsUI implements StatisticsControllerUI { + Throwable failWith; + + @Override public void execute(Statistics s) { + execute(s, null); + } + + @Override public void execute(Statistics s, LongTaskListener listener) { + new Thread(() -> { + if (failWith != null) { + try { + Method m = listener.getClass().getMethod("fatalError", Throwable.class); + m.invoke(listener, failWith); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + return; + } + s.execute(null); + listener.taskFinished(s instanceof LongTask ? (LongTask) s : null); + }).start(); + } + + @Override public void setStatisticsUIVisible(StatisticsUI ui, boolean visible) { + } + } + + @Test + void statisticRunThroughThePanelIsWaitedFor() throws Exception { + SlowStat stat = new SlowStat(150); + + boolean stopped = GephiControlService.executeStatistic(stat, null, 0, new FakeStatsUI(), DIRECT); + + assertFalse(stopped); + assertTrue(stat.ran.get()); + assertFalse(stat.cancelled.get()); + } + + @Test + void statisticPastItsDeadlineIsStoppedAndReported() throws Exception { + SlowStat stat = new SlowStat(60_000); + + long start = System.nanoTime(); + boolean stopped = GephiControlService.executeStatistic(stat, null, 200, new FakeStatsUI(), DIRECT); + + assertTrue(stopped); + assertTrue(stat.cancelled.get()); + assertTrue((System.nanoTime() - start) / 1_000_000 < 10_000); + } + + @Test + void failureReportedByGephiEndsTheWaitWithTheError() { + FakeStatsUI ui = new FakeStatsUI(); + ui.failWith = new IllegalStateException("boom"); + + RuntimeException e = assertThrows(RuntimeException.class, + () -> GephiControlService.executeStatistic(new SlowStat(60_000), null, 0, ui, DIRECT)); + + assertTrue(String.valueOf(e.getCause()).contains("boom"), String.valueOf(e)); + } + + @Test + void withoutThePanelTheStatisticRunsDirectly() throws Exception { + SlowStat stat = new SlowStat(10); + + assertFalse(GephiControlService.executeStatistic(stat, null, 0, null, DIRECT)); + assertTrue(stat.ran.get()); + } + + @Test + void stopRequestCancelsTheRunningStatisticAndMarksItStopped() throws Exception { + SlowStat stat = new SlowStat(60_000); + GephiControlService.RUNNING_STATISTICS.put(stat, "Slow"); + try { + Thread run = new Thread(() -> { + try { + GephiControlService.executeStatistic(stat, null, 0, new FakeStatsUI(), DIRECT); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + run.start(); + while (!stat.ran.get()) { + Thread.onSpinWait(); + } + + final com.google.gson.JsonObject r = GephiControlService.getInstance().stopStatistics(); + + run.join(10_000); + assertFalse(run.isAlive(), "the statistic kept running after the stop"); + assertTrue(stat.cancelled.get()); + assertEquals("Slow", r.getAsJsonArray("stopped").get(0).getAsString()); + assertTrue(GephiControlService.STOP_REQUESTED.contains(stat)); + } finally { + GephiControlService.RUNNING_STATISTICS.remove(stat); + GephiControlService.STOP_REQUESTED.remove(stat); + } + } + + @Test + void stopWithNothingRunningSaysSo() { + com.google.gson.JsonObject r = GephiControlService.getInstance().stopStatistics(); + + assertTrue(r.get("message").getAsString().contains("No statistic"), r.toString()); + } + + /** Records the order of controller calls and what the layout held at each. */ + static class FakeLayoutController implements LayoutController { + final List calls = new ArrayList<>(); + Layout selected; + + @Override public LayoutModel getModel() { + return null; + } + + @Override public LayoutModel getModel(Workspace w) { + return null; + } + + @Override public void setLayout(Layout l) { + selected = l; + calls.add("setLayout linLog=" + value(l, "linLogMode")); + } + + @Override public void executeLayout() { + calls.add("execute"); + } + + @Override public void executeLayout(int n) { + calls.add("execute " + n); + } + + public void executeLayout(Layout l) { + calls.add("executeNow"); + } + + @Override public boolean canExecute() { + return true; + } + + @Override public void stopLayout() { + calls.add("stop"); + } + + @Override public boolean canStop() { + return false; + } + } + + static Object value(Layout layout, String key) { + for (LayoutProperty p : layout.getProperties()) { + if (p.getCanonicalName() != null && p.getCanonicalName().contains("." + key + ".")) { + try { + return p.getProperty().getValue(); + } catch (Exception e) { + return null; + } + } + } + return null; + } + + @Test + void layoutIsSelectedThenGivenItsSettingsThenShownAgainThenRun() { + FakeLayoutController lc = new FakeLayoutController(); + Layout layout = new ForceAtlas2Builder().buildLayout(); + Map props = new LinkedHashMap<>(); + props.put("linLogMode", true); + props.put("barnesHutOptimize", true); + + List unapplied = GephiControlService.startLayoutThroughController( + lc, layout, props, 300, DIRECT); + + // Selecting reloads the settings Gephi saved for this layout, so Gephi AI's settings go + // on after it; selecting again makes the panel show them; then it runs. + assertEquals(List.of("setLayout linLog=false", "setLayout linLog=true", "execute 300"), lc.calls); + assertEquals(List.of("barnesHutOptimize"), unapplied); + assertEquals(true, value(layout, "linLogMode")); + } + + @Test + void settingsNotGivenStartFromTheLayoutsDefaults() { + FakeLayoutController lc = new FakeLayoutController(); + Layout layout = new ForceAtlas2Builder().buildLayout(); + layout.resetPropertiesValues(); + for (LayoutProperty p : layout.getProperties()) { + if (p.getCanonicalName().contains(".linLogMode.")) { + try { + p.getProperty().setValue(true); + } catch (Exception e) { + throw new AssertionError(e); + } + } + } + + GephiControlService.startLayoutThroughController(lc, layout, Map.of("gravity", 2.0), 10, DIRECT); + + assertEquals(false, value(layout, "linLogMode"), "a leftover setting must not carry into the run"); + } + + /** A dynamic statistic: Gephi steps it through the network's timeline window by window. */ + static class FakeDynamicStat implements org.gephi.statistics.spi.DynamicStatistics { + double window = 1; + double tick = 1; + + FakeDynamicStat() { + } + + FakeDynamicStat(double window, double tick) { + this.window = window; + this.tick = tick; + } + + @Override public void execute(GraphModel gm) { + } + + @Override public String getReport() { + return ""; + } + + @Override public void loop(org.gephi.graph.api.GraphView v, org.gephi.graph.api.Interval i) { + } + + @Override public void end() { + } + + @Override public double getWindow() { + return window; + } + + @Override public void setWindow(double w) { + window = w; + } + + @Override public double getTick() { + return tick; + } + + @Override public void setTick(double t) { + tick = t; + } + + @Override public org.gephi.graph.api.Interval getBounds() { + return null; + } + + @Override public void setBounds(org.gephi.graph.api.Interval b) { + } + } + + @Test + void dynamicStatisticIsRefusedOnANetworkWithoutTimeData() { + GraphModel gm = GraphModel.Factory.newInstance(); + gm.getDirectedGraph().addNode(gm.factory().newNode("a")); + + String problem = GephiControlService.dynamicStatisticProblem(new FakeDynamicStat(), gm); + + assertTrue(problem != null && problem.contains("time data"), String.valueOf(problem)); + } + + @Test + void dynamicStatisticIsAllowedWhenTheNetworkHasTimeData() { + org.gephi.graph.api.Configuration config = org.gephi.graph.api.Configuration.builder() + .timeRepresentation(org.gephi.graph.api.TimeRepresentation.INTERVAL).build(); + GraphModel gm = GraphModel.Factory.newInstance(config); + org.gephi.graph.api.Node n = gm.factory().newNode("a"); + n.addInterval(new org.gephi.graph.api.Interval(1, 5)); + gm.getDirectedGraph().addNode(n); + + assertEquals(null, GephiControlService.dynamicStatisticProblem(new FakeDynamicStat(), gm)); + } + + private static GraphModel timedNetwork(double low, double high) { + org.gephi.graph.api.Configuration config = org.gephi.graph.api.Configuration.builder() + .timeRepresentation(org.gephi.graph.api.TimeRepresentation.INTERVAL).build(); + GraphModel gm = GraphModel.Factory.newInstance(config); + org.gephi.graph.api.Node n = gm.factory().newNode("a"); + n.addInterval(new org.gephi.graph.api.Interval(low, high)); + gm.getDirectedGraph().addNode(n); + return gm; + } + + @Test + void dynamicStatisticWithoutAStepIsRefusedAndTheTimeSpanIsGiven() { + // Gephi's dynamic statistics start with window 0 and tick 0; its settings dialog fills + // them in. Run with tick 0, Gephi's loop never advances. + String problem = GephiControlService.dynamicStatisticProblem(new FakeDynamicStat(0, 0), + timedNetwork(1990, 2000)); + + assertTrue(problem != null && problem.contains("tick") && problem.contains("1990") + && problem.contains("2000"), String.valueOf(problem)); + } + + @Test + void windowWiderThanTheNetworksTimeSpanIsRefused() { + String problem = GephiControlService.dynamicStatisticProblem(new FakeDynamicStat(50, 1), + timedNetwork(1990, 2000)); + + assertTrue(problem != null && problem.contains("window"), String.valueOf(problem)); + } + + @Test + void dynamicStatisticWithAWindowAndStepInsideTheSpanRuns() { + assertEquals(null, GephiControlService.dynamicStatisticProblem(new FakeDynamicStat(2, 1), + timedNetwork(1990, 2000))); + } + + @Test + void anOrdinaryStatisticIsNeverRefusedForLackingTimeData() { + GraphModel gm = GraphModel.Factory.newInstance(); + + assertEquals(null, GephiControlService.dynamicStatisticProblem(new SlowStat(1), gm)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PanelResultTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PanelResultTest.java new file mode 100644 index 000000000..9fa6e5ef7 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PanelResultTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import javax.swing.JPanel; +import org.gephi.graph.api.GraphModel; +import org.gephi.statistics.spi.Statistics; +import org.gephi.statistics.spi.StatisticsUI; +import org.junit.jupiter.api.Test; + +/** + * The result line Gephi's Statistics panel shows for a statistic that just ran, read from the + * statistic's own UI. It gives a headline for statistics the plugin has no getter for, such as + * those from other Gephi plugins. + */ +class PanelResultTest { + + static class Quality implements Statistics { + final double value; + + Quality(double value) { + this.value = value; + } + + @Override public void execute(GraphModel graphModel) { + } + + @Override public String getReport() { + return ""; + } + } + + static class Other implements Statistics { + @Override public void execute(GraphModel graphModel) { + } + + @Override public String getReport() { + return ""; + } + } + + static class QualityUI implements StatisticsUI { + private final Class forClass; + private final String shown; + private final boolean fails; + + QualityUI(Class forClass, String shown, boolean fails) { + this.forClass = forClass; + this.shown = shown; + this.fails = fails; + } + + @Override public String getValue(Statistics statistics) { + if (fails) { + throw new IllegalStateException("not set up"); + } + return shown == null || shown.isBlank() ? shown : shown + " for " + ((Quality) statistics).value; + } + + @Override public String getValue() { + return "stale"; + } + + @Override public JPanel getSettingsPanel() { + return null; + } + + @Override public void setup(Statistics statistics) { + } + + @Override public void unsetup() { + } + + @Override public Class getStatisticsClass() { + return forClass; + } + + @Override public String getDisplayName() { + return "Quality"; + } + + @Override public String getShortDescription() { + return ""; + } + + @Override public String getCategory() { + return StatisticsUI.CATEGORY_NETWORK_OVERVIEW; + } + + @Override public int getPosition() { + return 0; + } + } + + @Test + void theUiForTheStatisticGivesItsResultForThisRun() { + List uis = List.of(new QualityUI(Other.class, "wrong", false), + new QualityUI(Quality.class, "Q", false)); + assertEquals("Q for 0.5", GephiControlService.panelResult(new Quality(0.5), uis)); + } + + @Test + void noResultWhenNoUiMatchesOrItShowsNothing() { + assertNull(GephiControlService.panelResult(new Quality(0.5), + List.of(new QualityUI(Other.class, "wrong", false)))); + assertNull(GephiControlService.panelResult(new Quality(0.5), + List.of(new QualityUI(Quality.class, null, false)))); + assertNull(GephiControlService.panelResult(new Quality(0.5), + List.of(new QualityUI(Quality.class, " ", false)))); + } + + @Test + void uiThatFailsLeavesTheRunsOwnResultAlone() { + assertNull(GephiControlService.panelResult(new Quality(0.5), + List.of(new QualityUI(Quality.class, "Q", true)))); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PartitionPaletteTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PartitionPaletteTest.java new file mode 100644 index 000000000..8e7dd3278 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PartitionPaletteTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.awt.Color; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** Every group gets its own colour, and the largest groups get the base colours. */ +class PartitionPaletteTest { + + @Test + void manyGroupsStillGetDistinctColours() { + Map counts = new HashMap<>(); + for (int i = 0; i < 40; i++) { + counts.put("g" + i, 100 - i); + } + + Map palette = GephiControlService.partitionPalette(counts); + + Set rgb = new HashSet<>(); + for (Color c : palette.values()) { + rgb.add(c.getRGB()); + } + assertEquals(40, rgb.size(), "a colour was reused"); + } + + @Test + void theLargestGroupGetsTheFirstColour() { + Map counts = Map.of("small", 2, "big", 50, "middle", 10); + + Map palette = GephiControlService.partitionPalette(counts); + + assertEquals(List.of("big", "middle", "small"), List.copyOf(palette.keySet())); + assertEquals(GephiControlService.BASE_PALETTE[0], palette.get("big")); + } + + /** + * The eight colours the skill documents, in the order that keeps the largest groups apart: + * on a network map any two groups can touch, so every pair of the first five stays + * distinguishable with normal vision and under simulated red and green colour blindness. + */ + @Test + void theFirstEightGroupsGetTheValidatedColoursInSizeOrder() { + int[][] validated = {{42, 120, 214}, {237, 161, 0}, {0, 131, 0}, {232, 123, 164}, + {74, 58, 167}, {227, 73, 72}, {27, 175, 122}, {235, 104, 52}}; + Map counts = new HashMap<>(); + for (int i = 0; i < 8; i++) { + counts.put("g" + i, 10 + i); + } + + List colours = List.copyOf(GephiControlService.partitionPalette(counts).values()); + + for (int i = 0; i < 8; i++) { + int[] v = validated[i]; + assertEquals(new Color(v[0], v[1], v[2]), colours.get(i), "colour " + i); + } + } + + @Test + void manyGroupsComeWithANote() { + JsonObject few = new JsonObject(); + GephiControlService.addPaletteNote(few, 5); + JsonObject many = new JsonObject(); + GephiControlService.addPaletteNote(many, 6); + + assertFalse(few.has("palette_note")); + assertTrue(many.has("palette_note")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PdfPageTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PdfPageTest.java new file mode 100644 index 000000000..e746955c0 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/PdfPageTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.junit.jupiter.api.Test; + +/** PDFs are US Letter, turned to landscape when the drawing is wider than it is tall. */ +class PdfPageTest { + + private static Graph graphAt(float[][] positions) { + GraphModel gm = GraphModel.Factory.newInstance(); + Graph g = gm.getGraph(); + for (int i = 0; i < positions.length; i++) { + Node n = gm.factory().newNode("n" + i); + n.setX(positions[i][0]); + n.setY(positions[i][1]); + g.addNode(n); + } + return g; + } + + @Test + void wideDrawingPrintsLandscape() { + assertTrue(GephiControlService.landscapeFor(graphAt(new float[][]{{-300, 0}, {300, 50}}))); + } + + @Test + void tallDrawingPrintsPortrait() { + assertFalse(GephiControlService.landscapeFor(graphAt(new float[][]{{0, -300}, {50, 300}}))); + } + + @Test + void squareOrSingleNodeDrawingPrintsPortrait() { + assertFalse(GephiControlService.landscapeFor(graphAt(new float[][]{{-10, -10}, {10, 10}}))); + assertFalse(GephiControlService.landscapeFor(graphAt(new float[][]{{5, 5}}))); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/RangeFilterTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/RangeFilterTest.java new file mode 100644 index 000000000..6bed4b617 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/RangeFilterTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphFactory; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.junit.jupiter.api.Test; + +/** + * The degree and edge-weight filters, checked on a hand-counted graph: which elements go, that + * the bounds are inclusive, that a max of 0 means no upper bound, and that the write lock is + * released afterwards. + */ +class RangeFilterTest { + + /** A hub with four leaves, and two leaves joined to each other: degrees hub 4, a 2, b 2, c 1, d 1. */ + private static Graph starWithOneExtraEdge() { + GraphModel gm = GraphModel.Factory.newInstance(); + Graph g = gm.getUndirectedGraph(); + GraphFactory f = gm.factory(); + Node hub = f.newNode("hub"); + g.addNode(hub); + String[] leaves = {"a", "b", "c", "d"}; + double w = 1; + for (String id : leaves) { + Node n = f.newNode(id); + g.addNode(n); + Edge e = f.newEdge(hub, n, 0, w++, false); + g.addEdge(e); + } + g.addEdge(f.newEdge(g.getNode("a"), g.getNode("b"), 0, 5.0, false)); + return g; + } + + @Test + void removesNodesBelowTheMinimumWithNoUpperBound() { + Graph g = starWithOneExtraEdge(); + assertEquals(2, GephiControlService.removeNodesOutsideDegreeRange(g, 2, 0)); + assertNull(g.getNode("c")); + assertNull(g.getNode("d")); + assertNotNull(g.getNode("hub")); + assertNotNull(g.getNode("a")); + assertEquals(0, g.getLock().getWriteHoldCount(), "write lock left held"); + } + + @Test + void degreeBoundsAreInclusive() { + Graph g = starWithOneExtraEdge(); + assertEquals(1, GephiControlService.removeNodesOutsideDegreeRange(g, 1, 2), "only the hub (4) is outside"); + assertNull(g.getNode("hub")); + assertEquals(4, g.getNodeCount()); + } + + @Test + void removesEdgesOutsideTheWeightRange() { + Graph g = starWithOneExtraEdge(); + // Weights 1, 2, 3, 4 on the spokes and 5 on a-b; keep [2, 4]. + assertEquals(2, GephiControlService.removeEdgesOutsideWeightRange(g, 2.0, 4.0)); + assertEquals(3, g.getEdgeCount()); + for (Edge e : g.getEdges().toArray()) { + assertTrue(e.getWeight() >= 2.0 && e.getWeight() <= 4.0, "kept weight " + e.getWeight()); + } + assertEquals(0, g.getLock().getWriteHoldCount(), "write lock left held"); + } + + @Test + void theDryRunListMatchesWhatIsRemoved() { + Graph g = starWithOneExtraEdge(); + int planned = GephiControlService.nodesOutsideDegreeRange(g, 2, 0).size(); + assertEquals(5, g.getNodeCount(), "a dry run changes nothing"); + assertEquals(planned, GephiControlService.removeNodesOutsideDegreeRange(g, 2, 0)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/SaveProjectTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/SaveProjectTest.java new file mode 100644 index 000000000..f7a917ce5 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/SaveProjectTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gephi.project.api.ProjectController; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openide.util.Lookup; + +/** "Project saved" is reported only when the file is on disk. */ +class SaveProjectTest { + + @Test + void saveReportsTheWrittenFile(@TempDir Path dir) { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.closeCurrentProject(); + pc.newProject(); + Path target = dir.resolve("saved.gephi"); + + JsonObject r = GephiControlService.getInstance().saveProject(target.toString()); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertTrue(Files.isRegularFile(target)); + assertTrue(r.get("bytes").getAsLong() > 0); + } + + @Test + void saveIntoAMissingFolderIsReportedAsFailed(@TempDir Path dir) { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.closeCurrentProject(); + pc.newProject(); + + JsonObject r = GephiControlService.getInstance() + .saveProject(dir.resolve("no-such-folder").resolve("x.gephi").toString()); + + assertFalse(r.get("success").getAsBoolean(), r.toString()); + } + + @Test + void fileLeftUnchangedIsNotASave(@TempDir Path dir) throws Exception { + File old = Files.writeString(dir.resolve("old.gephi"), "x").toFile(); + long stamp = System.currentTimeMillis() - 60_000; + assertTrue(old.setLastModified(stamp)); + + String problem = GephiControlService.savedFileProblem(old, old.lastModified(), System.currentTimeMillis()); + + assertTrue(problem != null && problem.contains("unchanged"), String.valueOf(problem)); + } + + @Test + void missingOrEmptyFileIsNotASave(@TempDir Path dir) throws Exception { + long now = System.currentTimeMillis(); + assertTrue(GephiControlService.savedFileProblem(dir.resolve("none").toFile(), -1, now).contains("not written")); + File empty = Files.createFile(dir.resolve("empty.gephi")).toFile(); + assertTrue(GephiControlService.savedFileProblem(empty, -1, now).contains("empty")); + Files.writeString(empty.toPath(), "data"); + assertEquals(null, GephiControlService.savedFileProblem(empty, -1, now)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ShortestPathTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ShortestPathTest.java new file mode 100644 index 000000000..67f5efcda --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/ShortestPathTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.stream.Collectors; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.junit.jupiter.api.Test; + +/** Shortest paths by steps, by edge length, and by tie strength. */ +class ShortestPathTest { + + /** a-b-d is two steps; a-c-d is two steps too; a-d directly is one step but weight 10. */ + private static GraphModel diamond(boolean directed) { + GraphModel gm = GraphModel.Factory.newInstance(); + Graph g = directed ? gm.getDirectedGraph() : gm.getUndirectedGraph(); + for (String id : new String[]{"a", "b", "c", "d"}) { + g.addNode(gm.factory().newNode(id)); + } + edge(gm, g, "a", "b", 1, directed); + edge(gm, g, "b", "d", 1, directed); + edge(gm, g, "a", "c", 3, directed); + edge(gm, g, "c", "d", 3, directed); + edge(gm, g, "a", "d", 10, directed); + return gm; + } + + private static void edge(GraphModel gm, Graph g, String s, String t, double w, boolean directed) { + Edge e = gm.factory().newEdge(g.getNode(s), g.getNode(t), 0, w, directed); + g.addEdge(e); + } + + private static List ids(GephiControlService.PathResult p) { + return p.nodes.stream().map(n -> (String) n.getId()).collect(Collectors.toList()); + } + + private static GephiControlService.PathResult path(GraphModel gm, String from, String to, + String weighting, boolean follow) { + Graph g = gm.getGraph(); + return GephiControlService.shortestPath(g, g.getNode(from), g.getNode(to), weighting, follow); + } + + @Test + void countingStepsTakesTheDirectEdge() { + GephiControlService.PathResult p = path(diamond(false), "a", "d", "none", true); + + assertEquals(List.of("a", "d"), ids(p)); + assertEquals(1, p.tiedPaths); + } + + @Test + void readingWeightAsLengthTakesTheShortRoute() { + GephiControlService.PathResult p = path(diamond(false), "a", "d", "distance", true); + + assertEquals(List.of("a", "b", "d"), ids(p)); + assertEquals(2.0, p.length, 1e-9); + } + + @Test + void readingWeightAsStrengthTakesTheStrongTie() { + GephiControlService.PathResult p = path(diamond(false), "a", "d", "strength", true); + + assertEquals(List.of("a", "d"), ids(p)); + assertEquals(0.1, p.length, 1e-9); + } + + @Test + void equallyShortPathsAreCounted() { + GraphModel gm = diamond(false); + Graph g = gm.getGraph(); + g.removeEdge(g.getEdge(g.getNode("a"), g.getNode("d"))); + + GephiControlService.PathResult p = path(gm, "a", "d", "none", true); + + assertEquals(2, p.nodes.size() - 1); + assertEquals(2, p.tiedPaths); + } + + @Test + void directedEdgesAreFollowedForwardsOnly() { + GraphModel gm = diamond(true); + + assertNull(path(gm, "d", "a", "none", true)); + assertEquals(List.of("d", "a"), ids(path(gm, "d", "a", "none", false))); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/SizeCapTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/SizeCapTest.java new file mode 100644 index 000000000..1517be2de --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/SizeCapTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * A cap keeps a few outliers (a mailing list with 220 contacts among people with 20) from + * shrinking every other node: values at or above the cap get the largest size, and the rest + * spread over the whole range. + */ +class SizeCapTest { + + @Test + void withoutACapTheRangeRunsFromTheSmallestToTheLargestValue() { + assertEquals(10f, GephiControlService.rankedSize(0, 0, 200, null, 10, 100), 1e-4); + assertEquals(100f, GephiControlService.rankedSize(200, 0, 200, null, 10, 100), 1e-4); + assertEquals(19f, GephiControlService.rankedSize(20, 0, 200, null, 10, 100), 1e-4); + } + + @Test + void valuesAtOrAboveTheCapGetTheLargestSize() { + assertEquals(100f, GephiControlService.rankedSize(200, 0, 200, 20.0, 10, 100), 1e-4); + assertEquals(100f, GephiControlService.rankedSize(20, 0, 200, 20.0, 10, 100), 1e-4); + } + + @Test + void valuesBelowTheCapSpreadOverTheWholeRange() { + assertEquals(55f, GephiControlService.rankedSize(10, 0, 200, 20.0, 10, 100), 1e-4); + } + + @Test + void capAboveTheLargestValueChangesNothing() { + assertEquals(19f, GephiControlService.rankedSize(20, 0, 200, 500.0, 10, 100), 1e-4); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/StatisticClaimTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/StatisticClaimTest.java new file mode 100644 index 000000000..2994bde5e --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/StatisticClaimTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.gephi.statistics.plugin.Degree; +import org.gephi.statistics.plugin.Modularity; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * One run of a statistic at a time. A second request for a statistic already running is refused + * rather than started alongside it, where it would repeat the work and keep the graph's read lock + * held for both runs. + */ +class StatisticClaimTest { + + @AfterEach + void clear() { + GephiControlService.RUNNING_STATISTICS.clear(); + } + + @Test + void secondRunOfTheSameStatisticIsRefused() { + assertNull(GephiControlService.claimStatistic(new Modularity(), "Modularity")); + String refused = GephiControlService.claimStatistic(new Modularity(), "Modularity"); + assertNotNull(refused, "a second Modularity run was allowed to start"); + assertTrue(refused.contains("already running"), refused); + } + + @Test + void differentStatisticMayRunAlongside() { + assertNull(GephiControlService.claimStatistic(new Modularity(), "Modularity")); + assertNull(GephiControlService.claimStatistic(new Degree(), "Degree")); + } + + @Test + void theStatisticCanRunAgainOnceTheFirstRunEnds() { + Modularity first = new Modularity(); + assertNull(GephiControlService.claimStatistic(first, "Modularity")); + GephiControlService.RUNNING_STATISTICS.remove(first); + assertNull(GephiControlService.claimStatistic(new Modularity(), "Modularity")); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/StatisticDeadlineTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/StatisticDeadlineTest.java new file mode 100644 index 000000000..eed72f6a5 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/StatisticDeadlineTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.gephi.utils.longtask.spi.LongTask; +import org.gephi.utils.progress.ProgressTicket; +import org.junit.jupiter.api.Test; + +/** + * Gephi's modularity can loop forever (gephi#1630). A statistic run with a deadline is cancelled + * when the deadline passes, and the caller is told it was stopped so a stale column is never read + * back as a result. + */ +class StatisticDeadlineTest { + + /** Spins until cancelled, the way a non-converging modularity run does. */ + static class Spinner implements LongTask, Runnable { + final AtomicBoolean cancelled = new AtomicBoolean(); + + @Override public void run() { + while (!cancelled.get()) { + Thread.onSpinWait(); + } + } + + @Override public boolean cancel() { + cancelled.set(true); + return true; + } + + @Override public void setProgressTicket(ProgressTicket t) { + } + } + + @Test + void taskThatNeverFinishesIsStoppedAtTheDeadline() { + Spinner s = new Spinner(); + long start = System.nanoTime(); + boolean stopped = GephiControlService.runWithDeadline(s, s, 200); + long ms = (System.nanoTime() - start) / 1_000_000; + + assertTrue(stopped); + assertTrue(s.cancelled.get()); + assertTrue(ms >= 150 && ms < 5_000, "stopped after " + ms + " ms"); + } + + @Test + void taskThatFinishesInTimeIsNotReportedAsStoppedOrCancelledLater() throws Exception { + Spinner s = new Spinner(); + boolean stopped = GephiControlService.runWithDeadline(() -> { + }, s, 200); + Thread.sleep(400); + + assertFalse(stopped); + assertFalse(s.cancelled.get(), "the deadline must be withdrawn once the task finishes"); + } + + @Test + void noDeadlineMeansNoWatchdog() { + Spinner s = new Spinner(); + assertFalse(GephiControlService.runWithDeadline(() -> { + }, s, 0)); + assertFalse(s.cancelled.get()); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/TimeSliceTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/TimeSliceTest.java new file mode 100644 index 000000000..9e75a55ab --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/TimeSliceTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.gephi.graph.api.GraphController; +import org.gephi.graph.api.Interval; +import org.gephi.graph.api.Node; +import org.gephi.graph.api.TimeRepresentation; +import org.gephi.project.api.ProjectController; +import org.gephi.project.api.Workspace; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.openide.util.Lookup; + +/** A time slice copies what is present in a window into its own workspace. */ +class TimeSliceTest { + + private static final String DYNAMIC = "\n" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + + @Test + void presenceOverlapsTheWindow() { + TimeRepresentation rep = TimeRepresentation.INTERVAL; + org.gephi.graph.api.GraphModel gm = org.gephi.graph.api.GraphModel.Factory.newInstance( + org.gephi.graph.api.Configuration.builder().timeRepresentation(rep).build()); + Node n = gm.factory().newNode("x"); + n.addInterval(new Interval(1990, 1995)); + gm.getDirectedGraph().addNode(n); + + assertTrue(GephiControlService.presentIn(n, 1995, 1999, rep)); + assertFalse(GephiControlService.presentIn(n, 1996, 1999, rep)); + assertTrue(GephiControlService.presentIn(gm.factory().newNode("timeless"), 0, 1, rep), + "no time data means always present"); + } + + @Test + void sliceOpensInItsOwnWorkspaceAndLeavesTheNetworkAlone(@TempDir Path dir) throws Exception { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.closeCurrentProject(); + pc.newProject(); + Path f = dir.resolve("dyn.gexf"); + Files.writeString(f, DYNAMIC, StandardCharsets.UTF_8); + GephiControlService.getInstance().importFile(f.toString(), null); + Workspace source = pc.getCurrentWorkspace(); + org.gephi.graph.api.GraphModel sourceModel = + Lookup.getDefault().lookup(GraphController.class).getGraphModel(source); + sourceModel.getGraph().getNode("a").setX(123f); + sourceModel.getGraph().getNode("a").setY(-45f); + + JsonObject r = GephiControlService.getInstance().timeSlice(1991, 1996); + + assertTrue(r.get("success").getAsBoolean(), r.toString()); + assertNotSame(source, pc.getCurrentWorkspace()); + org.gephi.graph.api.Graph sliced = Lookup.getDefault().lookup(GraphController.class) + .getGraphModel(pc.getCurrentWorkspace()).getGraph(); + assertEquals(2, sliced.getNodeCount(), "a and b are present, c is not"); + assertEquals(1, sliced.getEdgeCount(), "only a-b is present"); + assertEquals(123f, sliced.getNode("a").x(), "a slice keeps the network's positions"); + assertEquals(-45f, sliced.getNode("a").y()); + assertEquals(3, Lookup.getDefault().lookup(GraphController.class).getGraphModel(source) + .getGraph().getNodeCount(), "the network itself is untouched"); + } + + @Test + void networkWithoutTimeDataIsRefused() { + ProjectController pc = Lookup.getDefault().lookup(ProjectController.class); + pc.closeCurrentProject(); + pc.newProject(); + + JsonObject r = GephiControlService.getInstance().timeSlice(0, 1); + + assertFalse(r.get("success").getAsBoolean()); + assertTrue(r.get("error").getAsString().contains("gephi_set_time_from_columns"), r.toString()); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/VisibleCountTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/VisibleCountTest.java new file mode 100644 index 000000000..73898f7df --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/VisibleCountTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.gephi.graph.api.Column; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.GraphView; +import org.gephi.graph.api.Node; +import org.gephi.graph.api.Subgraph; +import org.junit.jupiter.api.Test; + +/** + * Colouring through Gephi's Appearance API changes only what a filter leaves visible, so the + * counts in the reply are taken over the visible graph, not the whole one. + */ +class VisibleCountTest { + + @Test + void onlyVisibleElementsAreCountedWhenAFilterIsOn() { + GraphModel gm = GraphModel.Factory.newInstance(); + Column grp = gm.getNodeTable().addColumn("grp", String.class); + Column kind = gm.getEdgeTable().addColumn("kind", String.class); + Graph g = gm.getGraph(); + Node[] n = new Node[4]; + for (int i = 0; i < 4; i++) { + n[i] = gm.factory().newNode("n" + i); + n[i].setAttribute(grp, i < 3 ? "x" : null); + g.addNode(n[i]); + } + Edge e01 = gm.factory().newEdge(n[0], n[1], 0, false); + Edge e23 = gm.factory().newEdge(n[2], n[3], 0, false); + e01.setAttribute(kind, "k"); + e23.setAttribute(kind, "k"); + g.addEdge(e01); + g.addEdge(e23); + + // No filter: every node with a value, every edge. + assertEquals(3, GephiControlService.countVisible(gm, false, v -> v != null, grp)); + assertEquals(2, GephiControlService.countVisible(gm, true, v -> v != null, kind)); + + // A filter that keeps n0, n1 and their edge. + GraphView view = gm.createView(); + Subgraph sub = gm.getGraph(view); + sub.addNode(n[0]); + sub.addNode(n[1]); + sub.addEdge(e01); + gm.setVisibleView(view); + + assertEquals(2, GephiControlService.countVisible(gm, false, v -> v != null, grp)); + assertEquals(1, GephiControlService.countVisible(gm, true, v -> v != null, kind)); + } +} diff --git a/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/ui/PortParseTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/ui/PortParseTest.java new file mode 100644 index 000000000..d00de97c4 --- /dev/null +++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/ui/PortParseTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Matt Artz + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.gephi.plugins.mcp.ui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** Unit tests for the port field validation in the server control dialog. */ +class PortParseTest { + + @Test + void validPortsAreAccepted() { + assertEquals(8080, ServerControlPanel.parsePort("8080")); + assertEquals(1024, ServerControlPanel.parsePort("1024")); + assertEquals(65535, ServerControlPanel.parsePort("65535")); + assertEquals(8080, ServerControlPanel.parsePort(" 8080 ")); + } + + @Test + void invalidPortsAreRejected() { + assertEquals(-1, ServerControlPanel.parsePort(null)); + assertEquals(-1, ServerControlPanel.parsePort("")); + assertEquals(-1, ServerControlPanel.parsePort("abc")); + assertEquals(-1, ServerControlPanel.parsePort("-1")); + assertEquals(-1, ServerControlPanel.parsePort("0")); + assertEquals(-1, ServerControlPanel.parsePort("80")); + assertEquals(-1, ServerControlPanel.parsePort("1023")); + assertEquals(-1, ServerControlPanel.parsePort("65536")); + assertEquals(-1, ServerControlPanel.parsePort("8080.5")); + } +} diff --git a/pom.xml b/pom.xml index 8f2b05cde..2d0a04d42 100644 --- a/pom.xml +++ b/pom.xml @@ -331,6 +331,11 @@ modules/WeightedBridgingCentrality + + + + + modules/GephiAI