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());
+ result.add("renderer", service.rendererStats());
+ // 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;
+ // No size given: sizes stay as they are, so a colour reset keeps a size ranking.
+ Float size = body != null && body.has("size") && !body.get("size").isJsonNull()
+ ? body.get("size").getAsFloat() : null;
+ 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);
+ }
+
+ if ("/graph/generate".equals(uri) && "POST".equals(method)) {
+ if (body == null || !body.has("model") || !body.has("nodes")) {
+ return errorResult("Missing 'model' or 'nodes'");
+ }
+ Float k = floatOrNull(body, "edges_per_node");
+ Long seed = body.has("seed") && !body.get("seed").isJsonNull() ? body.get("seed").getAsLong() : null;
+ Double p = body.has("wiring_probability") && !body.get("wiring_probability").isJsonNull()
+ ? body.get("wiring_probability").getAsDouble() : null;
+ return service.generateGraph(body.get("model").getAsString(), body.get("nodes").getAsInt(),
+ k == null ? null : k.intValue(), p, seed);
+ }
+
+ 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..68efb5ef0
--- /dev/null
+++ b/modules/GephiAI/src/main/java/org/gephi/plugins/mcp/service/GephiControlService.java
@@ -0,0 +1,7360 @@
+/*
+ * 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.PreviewProperties;
+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;
+ /** The last layout started: algorithm, iterations and the settings read back, for get_layout_status. */
+ private volatile JsonObject lastLayoutRun = 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. GraphLock offers a timed
+ * acquisition for reads only (a queued timed writer would stall new readers), so the
+ * underlying WriteLock 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 ("
+ + describeLock(g) + ")");
+ }
+ Thread.sleep(5);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while acquiring the write lock");
+ } catch (RuntimeException e) {
+ throw e; // the busy message above, which the caller shows as it is
+ } 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();
+ }
+
+ /*
+ * 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
+ * tryReadLock turns that into an immediate, actionable error instead.
+ */
+ static void lockRead(Graph g) {
+ lockRead(g, 10_000_000_000L);
+ }
+
+ /** lockRead with its overall wait, in nanoseconds; package-private for tests. */
+ static void lockRead(Graph g, long timeoutNanos) {
+ org.gephi.graph.api.GraphLock lock = g.getLock();
+ long deadline = System.nanoTime() + timeoutNanos;
+ try {
+ while (!lock.tryReadLock(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 (" + describeLock(g) + ")");
+ }
+ Thread.sleep(5);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while acquiring the read lock");
+ }
+ }
+
+ /**
+ * The graph lock's live state for a "busy" message, so a leaked read hold (read holds while
+ * nothing runs) can be told from a long statistic. Never throws.
+ */
+ static String describeLock(Graph g) {
+ try {
+ org.gephi.graph.api.GraphLock lock = g.getLock();
+ return lock.getReadLockCount() + " read holds, write lock "
+ + (lock.isWriteLocked() ? "held" : "free") + ", " + lock.getQueueLength() + " threads waiting";
+ } catch (Throwable t) {
+ return "lock state unavailable";
+ }
+ }
+
+ /** 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 = outputFile(filePath);
+ // 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 and could not be created");
+ }
+ 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();
+ int index = 0;
+ for (Workspace ws : pc.getCurrentProject().getWorkspaces()) {
+ JsonObject o = new JsonObject();
+ // The position is what switch, delete, rename, duplicate and compare take; the Gephi
+ // id is not, and the two diverge as soon as a workspace is deleted.
+ o.addProperty("index", index++);
+ 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);
+ }
+
+ /**
+ * The node with this id, or null. Ids arrive as text, but a graph imported with integer or
+ * long ids keys its nodes by number, so when the text itself finds nothing it is read as the
+ * graph's id type and looked up again.
+ */
+ static Node nodeById(Graph g, String id) {
+ if (id == null) {
+ return null;
+ }
+ Node n = g.getNode(id);
+ if (n != null) {
+ return n;
+ }
+ Object typed = typedNodeId(g.getModel().getConfiguration().getNodeIdType(), id);
+ return typed == null ? null : g.getNode(typed);
+ }
+
+ /** The id to create a node with: the text as the graph's id type when the graph keys by number. */
+ static Object nodeIdFor(GraphModel gm, String id) {
+ Object typed = typedNodeId(gm.getConfiguration().getNodeIdType(), id);
+ return typed != null ? typed : id;
+ }
+
+ /** Text read as a numeric id type, or null when the type is not numeric or the text not a number. */
+ static Object typedNodeId(Class> type, String text) {
+ try {
+ if (type == Integer.class) {
+ return Integer.valueOf(text.trim());
+ }
+ if (type == Long.class) {
+ return Long.valueOf(text.trim());
+ }
+ if (type == Short.class) {
+ return Short.valueOf(text.trim());
+ }
+ if (type == Byte.class) {
+ return Byte.valueOf(text.trim());
+ }
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ return null;
+ }
+
+ /**
+ * 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 (nodeById(g, id) != null) {
+ return error("Node exists: " + id);
+ }
+ Node n = gm.factory().newNode(nodeIdFor(gm, 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 || nodeById(g, id) != null) {
+ skipped++;
+ continue;
+ }
+ String label = (String) nd.getOrDefault("label", id);
+ Node n = gm.factory().newNode(nodeIdFor(gm, 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 = nodeById(g, 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 = nodeById(g, 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 = nodeById(g, 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 = nodeById(g, 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 = nodeById(g, 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 = nodeById(g, 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 = nodeById(g, src);
+ Node t = nodeById(g, 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 = nodeById(g, src);
+ Node t = nodeById(g, 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 = nodeById(g, source);
+ Node t = nodeById(g, 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 = nodeById(g, source);
+ Node t = nodeById(g, 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 = nodeById(g, source);
+ Node t = nodeById(g, 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();
+ // As the Gephi Density and Average Degree statistics count them: a directed tie
+ // fills one of n(n-1) ordered pairs and adds one to the mean in-degree and the mean
+ // out-degree; an undirected tie fills one of n(n-1)/2 pairs and adds to two degrees.
+ double ties = gm.isDirected() ? ec : 2.0 * ec;
+ double density = nc > 1 ? ties / ((double) nc * (nc - 1)) : 0;
+ double avgDeg = nc > 0 ? ties / 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 = nodeById(g, 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 = nodeById(g, 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 = nodeById(g, source);
+ Node t = nodeById(g, 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 = nodeById(graph, 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 = nodeById(graph, 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 = nodeById(graph, source);
+ Node t = nodeById(graph, 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 = nodeById(graph, 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);
+ }
+ }
+
+ /** Every node to one colour, and to one size when {@code size} is not null. */
+ 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);
+ if (size != null) {
+ n.setSize(size);
+ }
+ }
+ return success(size != null ? "Colour and size reset for all nodes"
+ : "Colour reset for all nodes; sizes kept");
+ } 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 extends org.gephi.appearance.spi.Transformer> 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 applied = appliedLayoutSettings(layout, properties);
+ JsonObject lastRun = new JsonObject();
+ lastRun.addProperty("layout", algo);
+ lastRun.addProperty("iterations", panelIters);
+ lastRun.add("applied", applied);
+ lastRun.addProperty("applied_note", APPLIED_NOTE);
+ lastLayoutRun = lastRun;
+ JsonObject r = new JsonObject();
+ r.addProperty("success", true);
+ r.addProperty("layout", algo);
+ r.addProperty("status", "running");
+ r.addProperty("iterations", panelIters);
+ r.add("applied", applied.deepCopy());
+ r.addProperty("applied_note", APPLIED_NOTE);
+ 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());
+ }
+ JsonObject lastRun = lastLayoutRun;
+ if (lastRun != null) {
+ r.add("last_run", lastRun.deepCopy());
+ }
+ 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();
+ String key = layoutPropertyKey(prop);
+ if (!key.isEmpty()) {
+ o.addProperty("key", key);
+ }
+ 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.addProperty("values_are", "defaults of a fresh layout, never what a run or a staged setting"
+ + " used; read applied in the gephi_run_layout result or last_run in gephi_get_layout_status."
+ + " Pass the key to gephi_run_layout.");
+ 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) {
+ Object val = null;
+ for (Map.Entry e : properties.entrySet()) {
+ String k = e.getKey();
+ if (layoutPropertyMatches(prop, k)) {
+ 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;
+ }
+
+ /**
+ * Said with every applied block. Agents took a bare applied block for the server repeating the
+ * request and spent many calls checking it another way, including against the defaults that
+ * gephi_get_layout_properties reports.
+ */
+ static final String APPLIED_NOTE = "These values were read back from the layout after the settings"
+ + " were set, so they are what this run uses. gephi_get_layout_properties shows defaults only and"
+ + " cannot confirm a setting.";
+
+ /** The middle part of an "AlgoName.propertyKey.name" canonical name, or "" when there is none. */
+ private static String layoutPropertyKey(LayoutProperty prop) {
+ String canonicalName = prop.getCanonicalName() != null ? prop.getCanonicalName() : "";
+ String[] parts = canonicalName.split("\\.");
+ return parts.length >= 3 ? parts[parts.length - 2] : "";
+ }
+
+ /** Whether a key from the caller names this property: its key, display name or canonical name, any case. */
+ private static boolean layoutPropertyMatches(LayoutProperty prop, String key) {
+ String canonicalName = prop.getCanonicalName() != null ? prop.getCanonicalName() : "";
+ String canonicalKey = layoutPropertyKey(prop);
+ return (!canonicalKey.isEmpty() && key.equalsIgnoreCase(canonicalKey))
+ || key.equalsIgnoreCase(prop.getProperty().getDisplayName())
+ || (!canonicalName.isEmpty() && key.equalsIgnoreCase(canonicalName));
+ }
+
+ /**
+ * The value each requested setting holds now, read back from the layout under the property
+ * key (its display name when it has none), so a caller can confirm what a run used instead of
+ * inferring it from the absence of unapplied_params. Keys that matched nothing are left out.
+ */
+ static JsonObject appliedLayoutSettings(Layout layout, Map properties) {
+ JsonObject applied = new JsonObject();
+ LayoutProperty[] props = layout.getProperties();
+ if (properties == null || props == null) {
+ return applied;
+ }
+ for (LayoutProperty prop : props) {
+ boolean asked = false;
+ for (String k : properties.keySet()) {
+ asked |= layoutPropertyMatches(prop, k);
+ }
+ if (!asked) {
+ continue;
+ }
+ String key = layoutPropertyKey(prop);
+ if (key.isEmpty()) {
+ key = prop.getProperty().getDisplayName();
+ }
+ Object value;
+ try {
+ value = prop.getProperty().getValue();
+ } catch (Exception e) {
+ LOGGER.log(Level.FINE, "Reading layout property " + key + " failed", e);
+ continue;
+ }
+ if (value instanceof Number) {
+ applied.addProperty(key, (Number) value);
+ } else if (value instanceof Boolean) {
+ applied.addProperty(key, (Boolean) value);
+ } else if (value != null) {
+ applied.addProperty(key, value.toString());
+ }
+ }
+ return applied;
+ }
+
+ /** 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 == null ? null : new java.util.LinkedHashMap<>(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");
+ r.add("staged", appliedLayoutSettings(layout, properties));
+ 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;
+ });
+
+ /** The stack Gephi 0.11.4 gives its own statistic workers (gephi#3327). */
+ static final long STATISTIC_STACK_BYTES = 128L * 1024 * 1024;
+
+ /**
+ * Runs {@code work} on a worker with Gephi's statistic stack size and waits for it, so a
+ * statistic that recurses once per node of a long path (connected components on a deep
+ * directed graph) does not overflow the request thread's small stack. Whatever it throws is
+ * thrown here. If the request is interrupted, {@code cancel} stops the statistic, so it does
+ * not go on writing columns after its request has given up.
+ */
+ static void runOnStatisticThread(Runnable work, Runnable cancel) {
+ java.util.concurrent.atomic.AtomicReference thrown =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ Thread worker = new Thread(null, () -> {
+ try {
+ work.run();
+ } catch (Throwable t) {
+ thrown.set(t);
+ }
+ }, "Gephi AI statistic", STATISTIC_STACK_BYTES);
+ worker.setDaemon(true);
+ worker.start();
+ try {
+ worker.join();
+ } catch (InterruptedException e) {
+ cancel.run();
+ worker.interrupt();
+ try {
+ worker.join(STATISTIC_STOP_GRACE_MS);
+ } catch (InterruptedException again) {
+ // Already interrupted; the flag is restored below.
+ }
+ Thread.currentThread().interrupt();
+ throw new RuntimeException("Interrupted while a statistic was running; it was stopped");
+ }
+ Throwable t = thrown.get();
+ if (t instanceof RuntimeException) {
+ throw (RuntimeException) t;
+ }
+ if (t instanceof Error) {
+ throw (Error) t;
+ }
+ if (t != null) {
+ throw new RuntimeException(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.
+ * A statistic that outlasts its deadline is cancelled (statistic loops check the cancel flag),
+ * so it releases the graph lock it holds instead of keeping every write waiting.
+ */
+ 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;
+
+ /** How a wait for the panel's callback ended. */
+ enum PanelWait { FINISHED, TIMED_OUT, LOST }
+
+ /**
+ * Waits for the panel's callback, for at most {@code waitMs}. The panel clears a statistic's
+ * running mark just before it calls back; a mark that is gone with no callback after
+ * {@link #PANEL_FINISH_GRACE_MS} means the run ended without reporting (Gephi sends a failure
+ * in the panel's own completion code to its error handler, not to this listener), so the wait
+ * ends there instead of at the one-hour cap.
+ */
+ static PanelWait awaitPanelRun(java.util.concurrent.CountDownLatch done, long waitMs, PanelRunMark mark)
+ throws InterruptedException {
+ final long deadline = System.nanoTime() + waitMs * 1_000_000L;
+ while (true) {
+ long leftMs = (deadline - System.nanoTime()) / 1_000_000L;
+ if (leftMs <= 0) {
+ return PanelWait.TIMED_OUT;
+ }
+ if (done.await(Math.min(leftMs, 1000), java.util.concurrent.TimeUnit.MILLISECONDS)) {
+ return PanelWait.FINISHED;
+ }
+ if (!mark.isMarked()
+ && !done.await(PANEL_FINISH_GRACE_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+ return PanelWait.LOST;
+ }
+ }
+ }
+
+ /** Hand-over states: not started yet, started on the interface thread, given up by the caller. */
+ static final int HAND_OVER_PENDING = 0;
+ static final int HAND_OVER_STARTED = 1;
+ static final int HAND_OVER_ABANDONED = 2;
+
+ /** How long a run whose running mark has gone may still report back. */
+ static final long PANEL_FINISH_GRACE_MS = 2000;
+ /** 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;
+ }
+
+ /**
+ * The Statistics panel's controller, or null when the panel cannot run a statistic in this
+ * workspace. The panel's controller is registered whenever Gephi's desktop modules are
+ * loaded, but it runs nothing until the Statistics window has given the workspace its model;
+ * before that its execute() returns at once and never reports back, so waiting on it would
+ * hold the request for the full wait cap. The window adds that model to the workspace, so
+ * its absence means the statistic has to run directly.
+ */
+ static org.gephi.desktop.statistics.api.StatisticsControllerUI statisticsPanelFor(Workspace ws,
+ org.gephi.desktop.statistics.api.StatisticsControllerUI panel) {
+ if (panel == null || ws == null
+ || ws.getLookup().lookup(org.gephi.desktop.statistics.api.StatisticsModelUI.class) == null) {
+ return null;
+ }
+ return panel;
+ }
+
+ /**
+ * What the plugin can see of Gephi's running mark for one statistic. Gephi's Statistics panel
+ * marks a statistic running inside {@code execute()}, before it hands it to the controller, and
+ * clears the mark when the run ends, before it calls back.
+ */
+ interface PanelRunMark extends AutoCloseable {
+
+ /** For callers that cannot observe the panel: always marked, so the panel is trusted. */
+ PanelRunMark TRUSTED = new PanelRunMark() {
+ @Override
+ public boolean isMarked() {
+ return true;
+ }
+
+ @Override
+ public boolean wasEverMarked() {
+ return true;
+ }
+
+ @Override
+ public void close() {
+ }
+ };
+
+ /** Whether the statistic is marked running now. */
+ boolean isMarked();
+
+ /** Whether it has been marked at any point since watching began. */
+ boolean wasEverMarked();
+
+ @Override
+ void close();
+ }
+
+ /**
+ * Whether this Statistics model shows this statistic as running. Its public interface shows
+ * one running instance per statistic class; another instance of the same class (a run the
+ * person started from the panel) is ambiguous, and the panel is trusted rather than risk a
+ * second run. So is a model with no panel entry for the statistic.
+ */
+ static boolean panelIsRunning(org.gephi.desktop.statistics.api.StatisticsModelUI model, Statistics stat,
+ java.util.Collection extends org.gephi.statistics.spi.StatisticsUI> uis) {
+ if (model == null) {
+ return false;
+ }
+ boolean checked = false;
+ for (org.gephi.statistics.spi.StatisticsUI ui : uis) {
+ if (ui.getStatisticsClass() != null && ui.getStatisticsClass().equals(stat.getClass())) {
+ checked = true;
+ if (model.getRunning(ui) != null) {
+ return true;
+ }
+ }
+ }
+ return !checked;
+ }
+
+ /** Statistics instances a Statistics model has shown as running, recorded by its one listener. */
+ private static final java.util.Set SEEN_MARKED =
+ java.util.Collections.synchronizedSet(java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>()));
+
+ /** Statistics models that already carry the plugin's listener. */
+ private static final java.util.Set HOOKED =
+ java.util.Collections.synchronizedSet(java.util.Collections.newSetFromMap(new java.util.WeakHashMap<>()));
+
+ /** Records the statistics a model now shows as running. */
+ private static void recordMarked(org.gephi.desktop.statistics.api.StatisticsModelUI model,
+ java.util.Collection extends org.gephi.statistics.spi.StatisticsUI> uis) {
+ for (org.gephi.statistics.spi.StatisticsUI ui : uis) {
+ Statistics running = model.getRunning(ui);
+ if (running != null) {
+ SEEN_MARKED.add(running);
+ }
+ }
+ }
+
+ /**
+ * Watches every workspace's Statistics model for this statistic's running mark. The panel
+ * marks the model it is bound to, which after a workspace change can be another workspace's,
+ * so all of them are watched. Each model gets one listener, added once and never removed:
+ * Gephi keeps listeners in a plain list that its statistic worker iterates, so adding and
+ * removing one per run would race with it. The listener records every statistic it sees
+ * marked, which catches a mark set and cleared between two checks.
+ */
+ static PanelRunMark watchPanel(Statistics stat,
+ java.util.Collection models,
+ java.util.Collection extends org.gephi.statistics.spi.StatisticsUI> uis) {
+ for (org.gephi.desktop.statistics.api.StatisticsModelUI m : models) {
+ if (HOOKED.add(m)) {
+ m.addChangeListener(e -> recordMarked(m, uis));
+ }
+ }
+ final java.util.function.BooleanSupplier marked = () -> {
+ for (org.gephi.desktop.statistics.api.StatisticsModelUI m : models) {
+ if (panelIsRunning(m, stat, uis)) {
+ SEEN_MARKED.add(stat);
+ return true;
+ }
+ }
+ return false;
+ };
+ return new PanelRunMark() {
+ @Override
+ public boolean isMarked() {
+ return marked.getAsBoolean();
+ }
+
+ @Override
+ public boolean wasEverMarked() {
+ return marked.getAsBoolean() || SEEN_MARKED.contains(stat);
+ }
+
+ @Override
+ public void close() {
+ SEEN_MARKED.remove(stat);
+ }
+ };
+ }
+
+ /** {@link #watchPanel} over the current project's workspaces. */
+ static PanelRunMark watchPanel(Statistics stat) {
+ java.util.List models = new java.util.ArrayList<>();
+ ProjectController pc = Lookup.getDefault().lookup(ProjectController.class);
+ Project project = pc == null ? null : pc.getCurrentProject();
+ if (project != null) {
+ for (Workspace w : project.getWorkspaces()) {
+ org.gephi.desktop.statistics.api.StatisticsModelUI m =
+ w.getLookup().lookup(org.gephi.desktop.statistics.api.StatisticsModelUI.class);
+ if (m != null) {
+ models.add(m);
+ }
+ }
+ }
+ return watchPanel(stat, models, Lookup.getDefault().lookupAll(StatisticsUI.class));
+ }
+
+ /**
+ * 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 {
+ return executeStatistic(stat, gm, timeoutMs, panel, onEdt, s -> PanelRunMark.TRUSTED);
+ }
+
+ static boolean executeStatistic(Statistics stat, GraphModel gm, long timeoutMs,
+ org.gephi.desktop.statistics.api.StatisticsControllerUI panel,
+ java.util.function.Consumer onEdt,
+ java.util.function.Function watch) throws InterruptedException {
+ if (panel == null) {
+ if (stat instanceof org.gephi.utils.longtask.spi.LongTask) {
+ org.gephi.utils.longtask.spi.LongTask task = (org.gephi.utils.longtask.spi.LongTask) stat;
+ return runWithDeadline(() -> runOnStatisticThread(() -> stat.execute(gm), task::cancel),
+ task, timeoutMs);
+ }
+ runOnStatisticThread(() -> 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();
+ }
+ };
+ // The watch is built on the interface thread right before execute(): the Statistics window
+ // binds the panel to a newly selected workspace on that same thread, so whichever model
+ // the panel marks is one being watched. If the hand-over times out before this step
+ // starts, the step must not start the statistic later, behind the caller's back; if it
+ // has already started, it is waited for.
+ final java.util.concurrent.atomic.AtomicInteger handOver = new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicReference markRef =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ final java.util.concurrent.CountDownLatch handedOver = new java.util.concurrent.CountDownLatch(1);
+ Runnable onInterfaceThread = () -> {
+ if (!handOver.compareAndSet(HAND_OVER_PENDING, HAND_OVER_STARTED)) {
+ return;
+ }
+ try {
+ markRef.set(watch.apply(stat));
+ panel.execute(stat, listener);
+ } finally {
+ handedOver.countDown();
+ }
+ };
+ try {
+ onEdt.accept(onInterfaceThread);
+ } catch (RuntimeException e) {
+ if (handOver.compareAndSet(HAND_OVER_PENDING, HAND_OVER_ABANDONED)) {
+ throw e;
+ }
+ if (!handedOver.await(STATISTIC_STOP_GRACE_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+ throw e;
+ }
+ }
+ PanelRunMark mark = markRef.get();
+ if (mark == null) {
+ throw new IllegalStateException("Gephi's Statistics panel was not reached");
+ }
+ try {
+ // The panel marks a statistic inside execute() before handing it over, so one never
+ // marked was declined (its model not yet bound after a workspace change) and will
+ // never run or report back. It is run directly instead.
+ if (done.getCount() > 0 && !mark.wasEverMarked()) {
+ LOGGER.info("Gephi's Statistics panel did not take " + stat.getClass().getSimpleName()
+ + "; running it directly");
+ return executeStatistic(stat, gm, timeoutMs, null, onEdt, watch);
+ }
+ return awaitPanelResult(stat, timeoutMs, done, failure, mark);
+ } finally {
+ mark.close();
+ }
+ }
+
+ private static boolean awaitPanelResult(Statistics stat, long timeoutMs,
+ java.util.concurrent.CountDownLatch done, java.util.concurrent.atomic.AtomicReference failure,
+ PanelRunMark mark) throws InterruptedException {
+ boolean stopped = false;
+ long wait = timeoutMs > 0 ? timeoutMs : STATISTIC_WAIT_CAP_MS;
+ PanelWait outcome = awaitPanelRun(done, wait, mark);
+ if (outcome == PanelWait.LOST) {
+ throw new IllegalStateException(stat.getClass().getSimpleName() + " ended in Gephi's Statistics"
+ + " panel without reporting back, so its result cannot be confirmed; run it again.");
+ }
+ if (outcome == PanelWait.TIMED_OUT) {
+ 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.
+ *
+ * Selection and settings are two event-thread tasks. In a workspace with no saved settings
+ * for the layout, Gephi's Layout panel answers the selection by posting a load of the default
+ * preset to the event thread, and with no preset saved that load resets the layout to its
+ * defaults. Posted inside one task, the reset ran after the settings and the layout ran on
+ * defaults; the second task is queued behind it, so the settings go on last.
+ */
+ 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));
+ onEdt.accept(() -> {
+ 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,
+ statisticsPanelFor(ws,
+ Lookup.getDefault().lookup(org.gephi.desktop.statistics.api.StatisticsControllerUI.class)),
+ this::onEdt,
+ GephiControlService::watchPanel);
+ } 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.");
+ 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 extends StatisticsUI> 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 {
+ // Reported under the name gephi_set_preview_settings takes, so a value can be
+ // read back by the key it was written with.
+ Object bgVal = pm.getProperties().getValue(PreviewProperty.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");
+ }
+ PreviewProperties props = pm.getProperties();
+ // Gephi's renderers register every preview property when the model is made. A name
+ // that is neither registered nor one of the model's plain values is a typo: storing
+ // it would change nothing in an export while the reply counted it as set.
+ boolean registryKnown = props.getProperties().length > 0;
+
+ int set = 0;
+ java.util.List unknown = new java.util.ArrayList<>();
+ java.util.List unapplied = new java.util.ArrayList<>();
+ 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)
+ || PreviewProperty.BACKGROUND_COLOR.equalsIgnoreCase(key)) {
+ Color bgColor = parseHexColor(val.toString());
+ if (bgColor == null) {
+ LOGGER.warning("MCP: Invalid background color: " + val);
+ unapplied.add(key);
+ continue;
+ }
+ PreviewProperty bgProp = props.getProperty(PreviewProperty.BACKGROUND_COLOR);
+ if (bgProp != null) {
+ bgProp.setValue(bgColor);
+ } else {
+ props.putValue(PreviewProperty.BACKGROUND_COLOR, bgColor);
+ }
+ set++;
+ continue;
+ }
+
+ PreviewProperty prop = props.getProperty(key);
+ if (prop == null) {
+ if (registryKnown && !props.hasProperty(key)) {
+ unknown.add(key);
+ continue;
+ }
+ // A plain model value (or a model whose registry is empty): putValue works
+ // regardless and renderers read it at export time. Non-scalar values are
+ // never valid preview properties; storing one corrupts the model.
+ if (val instanceof Map || val instanceof List) {
+ LOGGER.warning("MCP: Skipping non-scalar preview value for " + key);
+ unapplied.add(key);
+ continue;
+ }
+ props.putValue(key, coercePlainPreviewValue(val));
+ set++;
+ continue;
+ }
+ try {
+ if (applyPreviewValue(prop, val)) {
+ set++;
+ } else {
+ unapplied.add(key);
+ }
+ } catch (RuntimeException ex) {
+ LOGGER.warning("MCP: Failed to set preview property " + key + ": " + ex.getMessage());
+ unapplied.add(key);
+ }
+ }
+ JsonObject r = success("Set " + set + " preview properties");
+ r.addProperty("properties_set", set);
+ if (!unknown.isEmpty()) {
+ JsonArray a = new JsonArray();
+ unknown.forEach(a::add);
+ r.add("unknown_properties", a);
+ }
+ if (!unapplied.isEmpty()) {
+ JsonArray a = new JsonArray();
+ unapplied.forEach(a::add);
+ r.add("unapplied_properties", a);
+ }
+ if (!unknown.isEmpty() || !unapplied.isEmpty()) {
+ String notProperties = unknown.isEmpty() ? "" : "Not preview properties (see "
+ + "gephi_get_preview_settings for the names): " + unknown + ".";
+ String unread = unapplied.isEmpty() ? "" : "Values that could not be read for their "
+ + "setting, left unchanged: " + unapplied + ".";
+ r.addProperty("warning", (notProperties + " " + unread).trim());
+ }
+ return r;
+ } catch (Exception e) {
+ return failure("Failed: ", e);
+ }
+ }
+
+ /** A colour from "#rrggbb" or "rrggbb", or null when the text is not one. */
+ static Color parseHexColor(String text) {
+ String hex = text.trim();
+ if (hex.startsWith("#")) {
+ hex = hex.substring(1);
+ }
+ if (hex.length() != 6) {
+ return null;
+ }
+ try {
+ return new Color(Integer.parseInt(hex, 16));
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /** A plain preview value: "true"/"false" as booleans, numbers as floats, other text as given. */
+ static Object coercePlainPreviewValue(Object val) {
+ if (val instanceof String) {
+ String sv = ((String) val).trim();
+ if (sv.equalsIgnoreCase("true") || sv.equalsIgnoreCase("false")) {
+ return Boolean.parseBoolean(sv);
+ }
+ try {
+ return Float.parseFloat(sv);
+ } catch (NumberFormatException ignore) {
+ return val;
+ }
+ }
+ if (val instanceof Number) {
+ return ((Number) val).floatValue();
+ }
+ return val;
+ }
+
+ /** A true/false from a JSON boolean or the text "true"/"false", else null. */
+ static Boolean previewBoolean(Object val) {
+ if (val instanceof Boolean) {
+ return (Boolean) val;
+ }
+ String s = val.toString().trim();
+ if ("true".equalsIgnoreCase(s)) {
+ return Boolean.TRUE;
+ }
+ if ("false".equalsIgnoreCase(s)) {
+ return Boolean.FALSE;
+ }
+ return null;
+ }
+
+ /**
+ * Sets one registered preview property from a JSON value, converting it to the property's
+ * type. Returns false, changing nothing, when the value cannot be read as that type: a
+ * reading that guessed (any unrecognised text as false, say) would report success for a
+ * setting it never made.
+ */
+ private boolean applyPreviewValue(PreviewProperty prop, Object val) {
+ Class> type = prop.getType();
+ if (type == Color.class) {
+ Color c = val instanceof String ? parseHexColor((String) val) : null;
+ if (c == null) {
+ return false;
+ }
+ prop.setValue(c);
+ return true;
+ }
+ if (type == Boolean.class || type == boolean.class) {
+ Boolean b = previewBoolean(val);
+ if (b == null) {
+ return false;
+ }
+ prop.setValue(b);
+ return true;
+ }
+ if (type == Float.class || type == float.class) {
+ try {
+ prop.setValue(Float.parseFloat(val.toString().trim()));
+ return true;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+ if (type == Integer.class || type == int.class) {
+ // JSON numbers arrive as doubles, so 30 reads as "30.0": parse as a number, then
+ // accept it only when it is whole.
+ try {
+ double d = Double.parseDouble(val.toString().trim());
+ if (d != Math.rint(d) || Math.abs(d) > Integer.MAX_VALUE) {
+ return false;
+ }
+ prop.setValue((int) d);
+ return true;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+ if (type == java.awt.Font.class) {
+ if (!(val instanceof String)) {
+ return false;
+ }
+ prop.setValue(parsePreviewFont((String) val));
+ return true;
+ }
+ if (!(val instanceof String)) {
+ return false;
+ }
+ if (type == String.class) {
+ // Plugin renderers register text settings (a column name, a legend title); the
+ // value is kept as written, since column ids and titles are case-sensitive.
+ prop.setValue(val);
+ return true;
+ }
+ String s = val.toString().trim().toLowerCase();
+ if (type == DependantColor.class) {
+ 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 (parseHexColor(s) != null && s.startsWith("#")) {
+ prop.setValue(new DependantColor(parseHexColor(s)));
+ } else {
+ return false;
+ }
+ return true;
+ }
+ if (type == DependantOriginalColor.class) {
+ 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 (parseHexColor(s) != null && s.startsWith("#")) {
+ prop.setValue(new DependantOriginalColor(parseHexColor(s)));
+ } else {
+ return false;
+ }
+ return true;
+ }
+ if (type == EdgeColor.class) {
+ if ("source".equals(s) || "target".equals(s)) {
+ // For "source"/"target": color edges individually instead of using
+ // EdgeColor mode (which corrupts SVG rendering in Gephi 0.10)
+ boolean useSource = "source".equals(s);
+ Graph graph = currentGraphModel().getGraph();
+ java.util.Map nodeColors = new java.util.HashMap<>();
+ for (Node n : graph.getNodes().toArray()) {
+ nodeColors.put(n, n.getColor());
+ }
+ for (Edge edge : graph.getEdges().toArray()) {
+ Color c = nodeColors.get(useSource ? edge.getSource() : edge.getTarget());
+ 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 (parseHexColor(s) != null && s.startsWith("#")) {
+ prop.setValue(new EdgeColor(parseHexColor(s)));
+ } else {
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * A font from text like "Courier New 12 Bold": everything before the first digit is the
+ * name, the first number the size, and the words after it the style.
+ */
+ static java.awt.Font parsePreviewFont(String text) {
+ String fontStr = text.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;
+ }
+ return new java.awt.Font(name, fontStyle, fontSize);
+ }
+
+ // ─── Export ───────────────────────────────────────────────────────
+
+ /**
+ * The file an export writes, with its folder created first, so saving into a new folder
+ * works. If the folder cannot be made, the write that follows fails and reports it.
+ */
+ static File outputFile(String filePath) {
+ File file = new File(filePath).getAbsoluteFile();
+ File dir = file.getParentFile();
+ if (dir != null && !dir.isDirectory() && !dir.mkdirs()) {
+ LOGGER.fine("Could not create " + dir);
+ }
+ return file;
+ }
+
+ 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(outputFile(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(outputFile(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 = outputFile(filePath);
+ File targetDir = targetFile.getParentFile();
+ if (targetDir != null && !targetDir.isDirectory()) {
+ return error("Could not create the folder " + targetDir + " for the screenshot");
+ }
+
+ // 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");
+ }
+ // Until the graph canvas exists and draws, Gephi's screenshot fails with "No rendering
+ // engine available" (seen on the first call after Gephi starts). Wait for it briefly.
+ try {
+ if (!awaitRendererReady(() -> vizEngine().map(e -> java.util.OptionalLong.of(e.getFrameCount()))
+ .orElse(java.util.OptionalLong.empty()), RENDERER_READY_BUDGET_MS)) {
+ return error("Gephi's graph view is not drawing yet, so there is nothing to capture: show the"
+ + " Overview tab, wait for the graph to appear, and try again");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return error("Interrupted while waiting for Gephi's graph view");
+ }
+
+ // 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(outputFile(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(outputFile(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(outputFile(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(outputFile(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");
+ }
+
+ return loadContainer(c, file.getName(), "Imported from " + file.getName(), maxNodeSize, mode);
+ } catch (Exception e) {
+ return failure("Import failed: ", e);
+ }
+ }
+ }
+
+ /** Largest graph the preferential-attachment generator is asked for. */
+ static final int GENERATE_MAX_NODES = 100_000;
+ /** The random generator tests every pair of nodes, so it is held to fewer. */
+ static final int GENERATE_MAX_RANDOM_NODES = 20_000;
+ /** Largest edge count a generated graph may have (exact for preferential attachment, expected for random). */
+ static final long GENERATE_MAX_EXPECTED_EDGES = 2_000_000L;
+
+ /**
+ * Why a preferential-attachment graph of {@code nodes} nodes with {@code k} edges per node is
+ * refused, or null when it may be made. Its edge count is exact: a star of {@code k} edges, then
+ * {@code k} edges for each later node.
+ */
+ static String preferentialAttachmentRefusal(int nodes, int k) {
+ if (nodes < 2 || nodes > GENERATE_MAX_NODES) {
+ return "nodes must be between 2 and " + GENERATE_MAX_NODES;
+ }
+ if (k < 1 || k >= nodes) {
+ return "edges_per_node must be at least 1 and less than nodes (" + nodes + ")";
+ }
+ long edges = k + (long) (nodes - k - 1) * k;
+ if (edges > GENERATE_MAX_EXPECTED_EDGES) {
+ return "A preferential-attachment graph of " + nodes + " nodes with " + k + " edges per node has "
+ + edges + " edges, more than " + GENERATE_MAX_EXPECTED_EDGES + "; lower nodes or edges_per_node";
+ }
+ return null;
+ }
+
+ /**
+ * Why a random graph of {@code nodes} nodes with wiring probability {@code p} is refused, or
+ * null when it may be made. Its edge count is the expected one, {@code p} times the pairs.
+ */
+ static String randomGraphRefusal(int nodes, double p) {
+ if (nodes < 2 || nodes > GENERATE_MAX_RANDOM_NODES) {
+ return "nodes must be between 2 and " + GENERATE_MAX_RANDOM_NODES
+ + " for the random model, which tests every pair of nodes";
+ }
+ if (!(p > 0 && p <= 1)) {
+ return "wiring_probability must be greater than 0 and at most 1";
+ }
+ double expected = p * nodes * (nodes - 1.0) / 2;
+ if (expected > GENERATE_MAX_EXPECTED_EDGES) {
+ return "A random graph of " + nodes + " nodes with wiring_probability " + p + " expects about "
+ + Math.round(expected) + " edges, more than " + GENERATE_MAX_EXPECTED_EDGES
+ + "; lower nodes or wiring_probability";
+ }
+ return null;
+ }
+
+ /**
+ * Makes a graph with one of Gephi's generators and loads it into a new workspace, as Gephi's
+ * File > Generate does. "preferential_attachment" (Barabasi-Albert, Gephi 0.11.4) starts from a
+ * star of {@code edgesPerNode + 1} nodes and attaches each later node to that many existing
+ * nodes chosen in proportion to their degree; a seed is chosen and reported when none is
+ * given, so the graph can be made again. "random" (Erdos-Renyi) links each pair of nodes with
+ * {@code probability}; Gephi's random generator takes no seed, so it cannot be reproduced.
+ */
+ public JsonObject generateGraph(String model, int nodes, Integer edgesPerNode, Double probability, Long seed) {
+ String m = model == null ? "" : model.trim().toLowerCase(java.util.Locale.ROOT);
+ final org.gephi.io.generator.spi.Generator generator;
+ final String name;
+ JsonObject settings = new JsonObject();
+ switch (m) {
+ case "preferential_attachment":
+ case "barabasi_albert": {
+ int k = edgesPerNode == null ? 2 : edgesPerNode;
+ String refusal = preferentialAttachmentRefusal(nodes, k);
+ if (refusal != null) {
+ return error(refusal);
+ }
+ long s = seed != null ? seed : java.util.concurrent.ThreadLocalRandom.current().nextLong();
+ org.gephi.io.generator.plugin.ScaleFreeGraph g = new org.gephi.io.generator.plugin.ScaleFreeGraph();
+ g.setNumberOfNodes(nodes);
+ g.setEdgesPerNode(k);
+ g.setSeed(s);
+ generator = g;
+ name = "Preferential attachment (" + nodes + " nodes, " + k + " per node, seed " + s + ")";
+ settings.addProperty("model", "preferential_attachment");
+ settings.addProperty("edges_per_node", k);
+ settings.addProperty("seed", s);
+ settings.addProperty("reproducible", true);
+ break;
+ }
+ case "random":
+ case "erdos_renyi": {
+ double p = probability == null ? 0.05 : probability;
+ String refusal = randomGraphRefusal(nodes, p);
+ if (refusal != null) {
+ return error(refusal);
+ }
+ org.gephi.io.generator.plugin.RandomGraph g = new org.gephi.io.generator.plugin.RandomGraph();
+ g.setNumberOfNodes(nodes);
+ g.setWiringProbability(p);
+ generator = g;
+ name = "Random graph (" + nodes + " nodes, p " + p + ")";
+ settings.addProperty("model", "random");
+ settings.addProperty("wiring_probability", p);
+ settings.addProperty("reproducible", false);
+ settings.addProperty("note", "Gephi's random graph generator takes no seed, so this graph cannot"
+ + " be made again; save or export it to keep it.");
+ break;
+ }
+ default:
+ return error("Unknown model '" + model + "': use \"preferential_attachment\" or \"random\"");
+ }
+ try {
+ Container c = Lookup.getDefault().lookup(Container.Factory.class).newContainer();
+ c.setSource(generator.getName());
+ c.setReport(new org.gephi.io.importer.api.Report());
+ // Both models are undirected. The generators set no edge type, so without this the
+ // default of the container made every generated graph directed.
+ c.getLoader().setEdgeDefault(org.gephi.io.importer.api.EdgeDirectionDefault.UNDIRECTED);
+ generator.generate(c.getLoader());
+ JsonObject r = loadContainer(c, name, "Generated " + name, null, null);
+ if (r.get("success").getAsBoolean()) {
+ settings.entrySet().forEach(e -> r.add(e.getKey(), e.getValue()));
+ r.addProperty("directed", false);
+ r.addProperty("workspace", name);
+ }
+ return r;
+ } catch (Exception e) {
+ return failure("Generating the graph failed: ", e);
+ }
+ }
+
+ /**
+ * Loads a filled import container the way {@link #importFile} loads a file: into a new
+ * workspace named {@code name} by default (an empty workspace that was open is removed), or
+ * appended to the current one with {@code mode} "append". Reports Gephi's import issues and
+ * the resulting counts. Used for files and for generated graphs.
+ */
+ private JsonObject loadContainer(Container c, String name, String message, Float maxNodeSize, String mode)
+ throws Exception {
+ ImportController ic = Lookup.getDefault().lookup(ImportController.class);
+ 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();
+ // Only an untouched workspace is clutter: empty and still under Gephi's default
+ // name. One the person named is a decision, and stays.
+ org.gephi.project.api.WorkspaceInformation info =
+ previous.getLookup().lookup(org.gephi.project.api.WorkspaceInformation.class);
+ String previousName = info == null ? "" : info.getName();
+ previousEmpty = pg.getNodeCount() == 0 && pg.getEdgeCount() == 0
+ && previousName != null && previousName.matches("Workspace \\d+");
+ }
+ 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, name);
+ 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(message);
+ 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;
+ }
+
+ /** 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 = nodeById(g, 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();
+ FilterController fc = Lookup.getDefault().lookup(FilterController.class);
+ org.gephi.filters.api.FilterModel fm = fc == null ? null : fc.getModel();
+ // A filter still being computed would apply its result after the reset and bring
+ // itself back, so let it finish first.
+ awaitFilterApplied(fm);
+ // Turn the filter off through Gephi's filter controller, as the Filters panel's own
+ // Filter button does, so the panel stops showing it as active and a refresh cannot
+ // bring it back. Clearing takes Gephi's blocking write lock; holding the plugin's
+ // deadlock-safe lock first lets those calls re-enter instead of queuing.
+ lockWrite(g);
+ try {
+ if (fm != null && fm.isFiltering()) {
+ fc.filterVisible(null);
+ }
+ if (fm != null && fm.isSelecting()) {
+ fc.selectVisible(null);
+ }
+ if (fm == null) {
+ gm.setVisibleView(null);
+ }
+ } finally {
+ unlockWrite(g);
+ }
+ awaitFilterApplied(fm);
+ if (!gm.getVisibleView().isMainView()) {
+ // A view set some other way than through the Filters panel.
+ 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() {
+ }
+
+ /** The running render engine, empty until the graph canvas has been created. */
+ private static java.util.Optional extends org.gephi.viz.engine.VizEngine, ?>> vizEngine() {
+ org.gephi.visualization.api.VisualizationController vc =
+ Lookup.getDefault().lookup(org.gephi.visualization.api.VisualizationController.class);
+ return vc instanceof org.gephi.visualization.VizController
+ ? ((org.gephi.visualization.VizController) vc).getEngine() : java.util.Optional.empty();
+ }
+
+ /**
+ * Reads the renderer's two counters (Gephi 0.11.4). The engine commits world updates many
+ * times a second even when nothing changes, so frames that advance while world updates do
+ * not mean the world updaters are blocked, usually on the graph lock: the screen keeps
+ * showing the last picture while Gephi is hung. "not_drawing" means no frames at all.
+ */
+ static String rendererState(long framesAdvanced, long worldUpdatesAdvanced) {
+ if (framesAdvanced <= 0) {
+ return "not_drawing";
+ }
+ return worldUpdatesAdvanced > 0 ? "ok" : "stalled";
+ }
+
+ /**
+ * Waits until the renderer exists and has drawn a frame, for at most {@code budgetMs}.
+ * {@code frames} gives the frame count, or empty while there is no engine.
+ */
+ static boolean awaitRendererReady(java.util.function.Supplier frames, long budgetMs)
+ throws InterruptedException {
+ final long deadline = System.nanoTime() + budgetMs * 1_000_000L;
+ long first = -1;
+ while (true) {
+ java.util.OptionalLong now = frames.get();
+ if (now.isPresent()) {
+ if (first < 0) {
+ first = now.getAsLong();
+ } else if (now.getAsLong() > first) {
+ return true;
+ }
+ }
+ if (System.nanoTime() > deadline) {
+ return false;
+ }
+ Thread.sleep(25);
+ }
+ }
+
+ /**
+ * The renderer's counters, sampled twice, and the state they show: "not_started" until the
+ * graph canvas exists, then "ok", "stalled" or "not_drawing" (see {@link #rendererState}).
+ */
+ public JsonObject rendererStats() {
+ try {
+ return rendererStats(() -> vizEngine().map(e -> new long[] {e.getFrameCount(), e.getWorldUpdateCount()}),
+ RENDERER_SAMPLE_MS, RENDERER_STALL_CONFIRM_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ JsonObject o = new JsonObject();
+ o.addProperty("state", "unknown");
+ return o;
+ }
+ }
+
+ /**
+ * {@link #rendererStats()} over any source of {frames, world updates}, empty while there is
+ * no engine.
+ */
+ static JsonObject rendererStats(java.util.function.Supplier> counters, long sampleMs,
+ long confirmMs) throws InterruptedException {
+ JsonObject o = new JsonObject();
+ try {
+ java.util.Optional first = counters.get();
+ if (first.isEmpty()) {
+ o.addProperty("state", "not_started");
+ return o;
+ }
+ Thread.sleep(sampleMs);
+ long[] now = counters.get().orElse(first.get());
+ String state = rendererState(now[0] - first.get()[0], now[1] - first.get()[1]);
+ // A single slow world update (a very large graph, or a screenshot pausing updates)
+ // also leaves frames drawn without new world updates. Only updates that stay frozen
+ // for the confirmation time are a stall.
+ long watched = sampleMs;
+ while ("stalled".equals(state) && watched < confirmMs) {
+ Thread.sleep(sampleMs);
+ watched += sampleMs;
+ now = counters.get().orElse(now);
+ if (now[1] > first.get()[1]) {
+ state = "ok";
+ }
+ }
+ o.addProperty("state", state);
+ if ("stalled".equals(state)) {
+ o.addProperty("stalled_for_ms", watched);
+ }
+ o.addProperty("frames", now[0]);
+ o.addProperty("world_updates", now[1]);
+ } catch (RuntimeException e) {
+ o.addProperty("state", "unknown");
+ }
+ return o;
+ }
+
+ /** How long /health watches the renderer's counters. */
+ static final long RENDERER_SAMPLE_MS = 250;
+ /** How long world updates must stay frozen, with frames still drawn, before it is a stall. */
+ static final long RENDERER_STALL_CONFIRM_MS = 2000;
+ /** How long a screenshot waits for the graph view to start drawing. */
+ static final long RENDERER_READY_BUDGET_MS = 10_000;
+
+ /**
+ * 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();
+ if (g.getLock().tryReadLock(150, java.util.concurrent.TimeUnit.MILLISECONDS)) {
+ g.readUnlock();
+ return "ok";
+ }
+ return "busy";
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return "unknown";
+ } catch (Throwable t) {
+ return "unknown";
+ }
+ }
+
+ /**
+ * Live counters from GraphLock: 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;
+ }
+ o.addProperty("readers", lock.getReadLockCount());
+ o.addProperty("write_locked", lock.isWriteLocked());
+ o.addProperty("queued", lock.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 = nodeById(g, 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 = nodeById(g, source);
+ Node nt = nodeById(g, 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 = nodeById(g, 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);
+
+ // 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":
+ // Only a filter on the visible graph joins the Filters panel's queries. Adding
+ // one makes it the current query, and Gephi's two exports read a current query
+ // from its last result: with none yet, the column export wrote nothing and the
+ // workspace export copied nothing, while both reported success.
+ fc.add(query);
+ 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 computes its result in the background and returns before the
+ // view is swapped, so reading the counts at once reported the pre-filter
+ // numbers. Gephi's filter model says when the result has been applied.
+ boolean settled = awaitFilterApplied(fc.getModel());
+ 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, so later
+ // work runs on the subgraph alone and the original stays as it was.
+ 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;
+ }
+
+ /** How long a filter's result may take to be applied before its counts are called provisional. */
+ static final long FILTER_APPLY_BUDGET_MS = 30_000;
+
+ /**
+ * Waits until Gephi has applied the current filter's result. Since Gephi 0.11.4 the filter
+ * model reports a result being computed or applied as running. Returns false if it was still
+ * running when the budget ran out, so the caller can say the counts are provisional.
+ */
+ static boolean awaitFilterApplied(org.gephi.filters.api.FilterModel model) {
+ return awaitFilterApplied(model, FILTER_APPLY_BUDGET_MS);
+ }
+
+ static boolean awaitFilterApplied(org.gephi.filters.api.FilterModel model, long budgetMs) {
+ if (model == null) {
+ return true;
+ }
+ final long deadline = System.nanoTime() + budgetMs * 1_000_000L;
+ while (model.isRunning()) {
+ if (System.nanoTime() > deadline) {
+ return false;
+ }
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ }
+ return true;
+ }
+
+ // ─── 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 {
+ if (act.equals("select")) {
+ // Only here: see applyFilter for why the exports must not see it as current.
+ 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 = awaitFilterApplied(fc.getModel());
+ 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 = nodeById(g, fromId);
+ Node to = nodeById(g, 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);
+ Column ec = findColumn(gm.getEdgeTable(), markColumn);
+ // Checked before either column is made, so a refused mark adds nothing.
+ if ((nc != null && nc.getTypeClass() != Boolean.class)
+ || (ec != null && ec.getTypeClass() != Boolean.class)) {
+ return error("Column '" + markColumn
+ + "' already exists and is not true/false; choose another name");
+ }
+ if (nc == null) {
+ nc = gm.getNodeTable().addColumn(markColumn, Boolean.class);
+ }
+ if (ec == null) {
+ ec = gm.getEdgeTable().addColumn(markColumn, Boolean.class);
+ }
+ 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 = nodeById(g, id);
+ if (n == null) {
+ return error("Node not found: " + id);
+ }
+ nodes.add(n);
+ }
+ Node into = intoId != null ? nodeById(g, 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 && !format.startsWith(".")) {
+ // Gephi matches an exporter by its name ("GML exporter") or by an extension
+ // written with its dot (".gml"), so the bare extension finds nothing alone.
+ 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(outputFile(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);
+ if (created != null) {
+ // Gephi tests the whole value (Matcher.matches), so "^Dept-" marks nothing where
+ // "Dept-.*" marks every label starting "Dept-". The count makes an all-false
+ // column visible instead of looking like success.
+ int matched = 0;
+ org.gephi.graph.api.Element[] rows = table.isNodeTable()
+ ? gm.getGraph().getNodes().toArray() : gm.getGraph().getEdges().toArray();
+ for (org.gephi.graph.api.Element row : rows) {
+ if (Boolean.TRUE.equals(row.getAttribute(created))) {
+ matched++;
+ }
+ }
+ r.addProperty("matched", matched);
+ r.addProperty("rows", rows.length);
+ if (matched == 0 && rows.length > 0) {
+ r.addProperty("warning", "No value matched. The pattern must match the whole value:"
+ + " use \"Dept-.*\" for values starting \"Dept-\", or \".*word.*\" for values"
+ + " containing it.");
+ }
+ }
+ 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..b325b4550
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/SourceRulesTest.java
@@ -0,0 +1,369 @@
+/*
+ * 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",
+ "GephiControlService#runOnStatisticThread");
+
+ 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());
+ // The name is the last argument, or the one before a stack size.
+ if (!call.matches("(?s).*,\\s*\"[^\"]+\"\\s*(,\\s*[\\w.]+\\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;
+ }
+
+ /**
+ * Gephi 0.11.4 gives GraphLock a timed read and its live counters, so only the write lock,
+ * which has no timed acquisition in the public API, may still be reached by reflection.
+ */
+ @Test
+ void onlyTheWriteLockIsReachedByReflection() {
+ String svc = sources.get("GephiControlService.java");
+ org.junit.jupiter.api.Assertions.assertFalse(svc.contains("getDeclaredField(\"readLock\")"),
+ "the read lock must use GraphLock.tryReadLock");
+ org.junit.jupiter.api.Assertions.assertFalse(svc.contains("getDeclaredField(\"readWriteLock\")"),
+ "the lock counters must use the GraphLock API");
+ org.junit.jupiter.api.Assertions.assertTrue(svc.contains("getDeclaredField(\"writeLock\")"),
+ "lockWrite still needs its timed write handle");
+ }
+
+ @Test
+ void threadNameMayBeFollowedByAStackSize() {
+ org.junit.jupiter.api.Assertions.assertTrue(namedThread("new Thread(r, \"Worker\")"));
+ org.junit.jupiter.api.Assertions.assertTrue(namedThread("new Thread(null, r, \"Worker\", STACK_BYTES)"));
+ org.junit.jupiter.api.Assertions.assertFalse(namedThread("new Thread(r)"));
+ org.junit.jupiter.api.Assertions.assertFalse(namedThread("new Thread(null, r, STACK_BYTES)"));
+ }
+}
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/ApiRoutingTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiRoutingTest.java
new file mode 100644
index 000000000..982fedaad
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/api/ApiRoutingTest.java
@@ -0,0 +1,435 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.net.URLEncoder;
+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.ProjectController;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.openide.util.Lookup;
+
+/**
+ * Every endpoint's argument handling over a real connection: what a caller is told when a
+ * required value is missing or of the wrong type, and that the values it does send reach the
+ * tool. Each test starts from a fresh project holding a small network built through the API.
+ */
+class ApiRoutingTest {
+
+ 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();
+ }
+
+ /** A triangle a-b-c with d hanging off c, and e alone; each node has a group. */
+ @BeforeEach
+ void network() throws Exception {
+ ProjectController pc = Lookup.getDefault().lookup(ProjectController.class);
+ pc.closeCurrentProject();
+ pc.newProject();
+ ok(call("POST", "/graph/nodes/add", "{\"nodes\": ["
+ + "{\"id\": \"a\", \"label\": \"Ada Fennimore\", \"attributes\": {\"group\": \"x\", \"score\": 1}},"
+ + "{\"id\": \"b\", \"attributes\": {\"group\": \"x\", \"score\": 2}},"
+ + "{\"id\": \"c\", \"attributes\": {\"group\": \"y\", \"score\": 3}},"
+ + "{\"id\": \"d\", \"attributes\": {\"group\": \"y\", \"score\": 4}},"
+ + "{\"id\": \"e\", \"attributes\": {\"group\": \"x\", \"score\": 5}}]}"));
+ ok(call("POST", "/graph/edges/add", "{\"edges\": ["
+ + "{\"source\": \"a\", \"target\": \"b\", \"directed\": false, \"weight\": 1},"
+ + "{\"source\": \"b\", \"target\": \"c\", \"directed\": false, \"weight\": 2},"
+ + "{\"source\": \"c\", \"target\": \"a\", \"directed\": false, \"weight\": 3},"
+ + "{\"source\": \"c\", \"target\": \"d\", \"directed\": false, \"weight\": 4}]}"));
+ }
+
+ // ─── Plumbing ────────────────────────────────────────────────────
+
+ /** Sends a request and returns the status code followed by the parsed JSON body. */
+ private static Reply call(String method, String path, String body) throws Exception {
+ StringBuilder r = new StringBuilder(method + " " + path + " HTTP/1.1\r\nHost: 127.0.0.1\r\n");
+ byte[] bytes = body == null ? new byte[0] : body.getBytes(StandardCharsets.UTF_8);
+ if (body != null) {
+ r.append("Content-Type: application/json; charset=utf-8\r\n")
+ .append("Content-Length: ").append(bytes.length).append("\r\n");
+ }
+ r.append("Connection: close\r\n\r\n");
+ try (Socket s = new Socket("127.0.0.1", port)) {
+ s.setSoTimeout(30_000);
+ OutputStream out = s.getOutputStream();
+ out.write(r.toString().getBytes(StandardCharsets.UTF_8));
+ out.write(bytes);
+ out.flush();
+ InputStream in = s.getInputStream();
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
+ byte[] b = new byte[8192];
+ int n;
+ while ((n = in.read(b)) > 0) {
+ buf.write(b, 0, n);
+ }
+ String response = new String(buf.toByteArray(), StandardCharsets.UTF_8);
+ int status = Integer.parseInt(response.split(" ", 3)[1]);
+ String json = response.substring(response.indexOf("\r\n\r\n") + 4);
+ return new Reply(status, JsonParser.parseString(json).getAsJsonObject());
+ }
+ }
+
+ private static Reply call(String method, String path) throws Exception {
+ return call(method, path, null);
+ }
+
+ private static final class Reply {
+ final int status;
+ final JsonObject json;
+
+ Reply(int status, JsonObject json) {
+ this.status = status;
+ this.json = json;
+ }
+
+ @Override
+ public String toString() {
+ return status + " " + json;
+ }
+ }
+
+ private static JsonObject ok(Reply r) {
+ assertEquals(200, r.status, r.toString());
+ assertTrue(r.json.get("success").getAsBoolean(), r.toString());
+ return r.json;
+ }
+
+ /** The error text of a refused request, which must be a 4xx or 5xx with a non-empty error. */
+ private static String refused(Reply r) {
+ assertTrue(r.status >= 400, r.toString());
+ assertFalse(r.json.get("success").getAsBoolean(), r.toString());
+ assertTrue(r.json.has("error") && !r.json.get("error").isJsonNull(), "no error text: " + r);
+ String error = r.json.get("error").getAsString();
+ assertFalse(error.isBlank(), r.toString());
+ return error;
+ }
+
+ private static GraphModel model() {
+ return Lookup.getDefault().lookup(GraphController.class).getGraphModel();
+ }
+
+ private static String encoded(String s) {
+ return URLEncoder.encode(s, StandardCharsets.UTF_8);
+ }
+
+ // ─── Missing arguments ──────────────────────────────────────────
+
+ /**
+ * A request without its required values is refused with a message naming them, before any
+ * tool runs. Each row: method, path, body ("-" for none), text the error must contain.
+ */
+ @ParameterizedTest(name = "{0} {1} {2}")
+ @CsvSource(delimiter = '|', value = {
+ "POST | /perspective/switch | {} | 'name'",
+ "POST | /project/open | {} | 'file'",
+ "POST | /project/save | - | 'file'",
+ "POST | /workspace/switch | {} | 'index'",
+ "POST | /workspace/duplicate | {} | 'index'",
+ "POST | /workspace/rename | {\"index\": 0} | 'name'",
+ "DELETE | /workspace/delete?index=-2 | - | 'index'",
+ "POST | /graph/node/add | {} | 'id'",
+ "POST | /graph/nodes/add | {} | 'nodes'",
+ "POST | /graph/nodes/remove | {} | 'ids'",
+ "GET | /graph/node/get/ | - | node ID",
+ "POST | /graph/node/label | {\"id\": \"a\"} | 'label'",
+ "POST | /graph/node/position | {} | 'id'",
+ "POST | /graph/nodes/positions | {} | 'positions'",
+ "POST | /graph/edge/add | {\"source\": \"a\"} | 'target'",
+ "POST | /graph/edges/add | {} | 'edges'",
+ "POST | /graph/edge/remove | {\"target\": \"a\"} | 'source'",
+ "POST | /graph/edge/weight | {\"source\": \"a\", \"target\": \"b\"} | 'weight'",
+ "POST | /graph/edge/label | {\"source\": \"a\", \"target\": \"b\"} | 'label'",
+ "POST | /graph/columns/add | {\"name\": \"n\"} | 'type'",
+ "POST | /graph/node/attributes | {\"id\": \"a\"} | 'attributes'",
+ "POST | /graph/nodes/attributes | {} | 'updates'",
+ "POST | /graph/edge/attributes | {\"source\": \"a\", \"target\": \"b\"} | 'attributes'",
+ "POST | /appearance/node/color | {} | 'id'",
+ "POST | /appearance/node/size | {\"id\": \"a\"} | 'size'",
+ "POST | /appearance/edge/color | {\"source\": \"a\"} | 'target'",
+ "POST | /appearance/nodes/color | {} | 'nodes'",
+ "POST | /appearance/partition/color | {} | 'column'",
+ "POST | /appearance/edge/partition-color | {} | 'column'",
+ "POST | /appearance/ranking/color | {} | 'column'",
+ "POST | /appearance/ranking/size | {} | 'column'",
+ "POST | /layout/run | {} | 'algorithm'",
+ "GET | /layout/properties | - | 'algorithm'",
+ "POST | /layout/properties | {\"algorithm\": \"x\"} | 'properties'",
+ "POST | /statistics/run | {} | 'name'",
+ "POST | /filter/ego-network | {} | 'node_id'",
+ "POST | /filter/apply | {} | 'name'",
+ "POST | /filter/combine | {\"filters\": \"Degree Range\"} | 'filters'",
+ "POST | /time/from-columns | - | body",
+ "POST | /time/slice | {\"start\": 1} | 'end'",
+ "POST | /graph/shortest-path | {\"source\": \"a\"} | 'target'",
+ "POST | /datalab/column/edit | {\"column\": \"group\"} | 'action'",
+ "POST | /datalab/frequencies | {} | 'column'",
+ "POST | /datalab/duplicates | {} | 'column'",
+ "POST | /datalab/merge-nodes | {} | 'ids'",
+ "POST | /datalab/regex-column | {\"column\": \"group\", \"new_column\": \"n\"} | 'regex'",
+ "POST | /preview/settings | - | body",
+ "POST | /export/format | {\"file\": \"/tmp/x\"} | 'format'",
+ "POST | /export/png | {} | 'file'",
+ "POST | /export/screenshot | {} | 'file'",
+ "POST | /export/pdf | {} | 'file'",
+ "POST | /export/svg | {} | 'file'",
+ "POST | /export/graphml | {} | 'file'",
+ "POST | /export/csv | {} | 'file'",
+ "POST | /import/gexf | {} | 'file'",
+ "POST | /import/graphml | {} | 'file'",
+ "POST | /import/csv | {} | 'file'",
+ "POST | /import/file | {} | 'file'",
+ "POST | /graph/generate | {\"model\": \"random\"} | 'nodes'",
+ })
+ void requiredValuesAreNamedWhenMissing(String method, String path, String body, String expected)
+ throws Exception {
+ String error = refused(call(method, path, "-".equals(body) ? null : body));
+ assertTrue(error.contains(expected), error);
+ assertEquals(5, model().getGraph().getNodeCount(), "a refused request changed the graph");
+ }
+
+ @Test
+ void anEndpointCalledWithTheWrongMethodIsUnknown() throws Exception {
+ assertTrue(refused(call("GET", "/graph/node/add")).contains("Unknown endpoint: GET /graph/node/add"));
+ assertTrue(refused(call("POST", "/graph/nodes", "{}")).contains("Unknown endpoint"));
+ }
+
+ // ─── Values of the wrong type ───────────────────────────────────
+
+ @ParameterizedTest(name = "{0} {1}")
+ @CsvSource(delimiter = '|', value = {
+ "/graph/node/add | {\"id\": {\"nested\": true}}",
+ "/workspace/switch | {\"index\": \"first\"}",
+ "/graph/nodes/add | {\"nodes\": \"a,b\"}",
+ "/appearance/partition/color | {\"column\": \"group\", \"colors\": {\"x\": [0, 114]}}",
+ "/graph/edge/add | {\"source\": null, \"target\": \"b\"}",
+ "/time/slice | {\"start\": \"spring\", \"end\": 2}",
+ })
+ void valueOfTheWrongTypeIsReportedWithTheRequestItCameIn(String path, String body) throws Exception {
+ Reply r = call("POST", path, body);
+ String error = refused(r);
+ assertTrue(error.contains("POST " + path), error);
+ assertTrue(error.contains("wrong type"), error);
+ assertEquals(5, model().getGraph().getNodeCount(), "a refused request changed the graph");
+ }
+
+ @Test
+ void bodyThatIsNotAnObjectIsRefused() throws Exception {
+ String error = refused(call("POST", "/graph/nodes/add", "[1, 2]"));
+ assertTrue(error.contains("not a JSON object"), error);
+ }
+
+ // ─── Values reach the tool ──────────────────────────────────────
+
+ @Test
+ void nodeSearchParametersAreRead() throws Exception {
+ JsonObject r = ok(call("GET", "/graph/nodes?column=group&value=y&sort_by=score&descending=false"
+ + "&columns=score&limit=1"));
+ assertEquals(2, r.get("matches").getAsInt());
+ JsonObject first = r.getAsJsonArray("nodes").get(0).getAsJsonObject();
+ assertEquals("c", first.get("id").getAsString());
+ assertFalse(first.getAsJsonObject("attributes").has("group"));
+
+ JsonObject range = ok(call("GET", "/graph/nodes?column=score&min=2&max=4"));
+ assertEquals(3, range.get("matches").getAsInt());
+
+ JsonObject text = ok(call("GET", "/graph/nodes?column=label&contains=" + encoded("fenn")));
+ assertEquals(1, text.get("matches").getAsInt());
+
+ // An unreadable limit falls back to the default rather than failing the request.
+ assertEquals(5, ok(call("GET", "/graph/nodes?limit=lots")).get("count").getAsInt());
+ }
+
+ @Test
+ void nodesAreReadEditedAndRemovedByIdInThePath() throws Exception {
+ assertEquals("Ada Fennimore", ok(call("GET", "/graph/node/get/a")).getAsJsonObject("node")
+ .get("label").getAsString());
+ ok(call("POST", "/graph/node/label", "{\"id\": \"b\", \"label\": \"Kowalski\"}"));
+ ok(call("POST", "/graph/node/position", "{\"id\": \"b\", \"x\": 12.5, \"y\": -3}"));
+ ok(call("POST", "/appearance/node/size", "{\"id\": \"b\", \"size\": 22}"));
+ ok(call("POST", "/appearance/node/color", "{\"id\": \"b\", \"r\": 0, \"g\": 114, \"b\": 178}"));
+ JsonObject b = ok(call("GET", "/graph/node/get/b")).getAsJsonObject("node");
+ assertEquals("Kowalski", b.get("label").getAsString());
+ assertEquals(12.5, b.get("x").getAsDouble(), 1e-6);
+ assertEquals(22, b.get("size").getAsDouble(), 1e-6);
+ assertEquals(114, b.get("g").getAsInt());
+
+ ok(call("DELETE", "/graph/node/e"));
+ assertEquals(4, model().getGraph().getNodeCount());
+ assertTrue(refused(call("DELETE", "/graph/node/zz")).contains("zz"));
+ }
+
+ @Test
+ void edgesAreEditedAndPaged() throws Exception {
+ ok(call("POST", "/graph/edge/weight", "{\"source\": \"a\", \"target\": \"b\", \"weight\": 9}"));
+ ok(call("POST", "/graph/edge/label", "{\"source\": \"a\", \"target\": \"b\", \"label\": \"knows\"}"));
+ ok(call("POST", "/appearance/edge/color", "{\"source\": \"a\", \"target\": \"b\", \"r\": 230}"));
+ JsonObject page = ok(call("GET", "/graph/edges?limit=2&offset=1"));
+ assertEquals(2, page.getAsJsonArray("edges").size());
+ ok(call("POST", "/graph/edge/remove", "{\"source\": \"c\", \"target\": \"d\"}"));
+ assertEquals(3, model().getGraph().getEdgeCount());
+ }
+
+ @Test
+ void statisticsRunWithTheirParameters() throws Exception {
+ JsonObject r = ok(call("POST", "/statistics/run",
+ "{\"name\": \"Modularity\", \"params\": {\"resolution\": 1.0}}"));
+ assertFalse(r.has("unapplied_params"), r.toString());
+ assertTrue(ok(call("POST", "/statistics/degree", null)).has("average_degree"));
+ assertEquals(2, ok(call("POST", "/statistics/connected-components", null))
+ .get("connected_components").getAsInt());
+ assertTrue(ok(call("GET", "/statistics/available")).toString().contains("Degree"));
+ }
+
+ @Test
+ void shortestPathOptionsAreRead() throws Exception {
+ JsonObject r = ok(call("POST", "/graph/shortest-path", "{\"source\": \"a\", \"target\": \"d\","
+ + " \"weighting\": \"distance\", \"follow_direction\": false, \"mark_column\": \"route\"}"));
+ assertEquals(2, r.get("steps").getAsInt());
+ assertEquals("route", r.get("mark_column").getAsString());
+ }
+
+ @Test
+ void filterRequestsAreRead() throws Exception {
+ JsonObject dry = ok(call("POST", "/filter/degree", "{\"min\": 2, \"dry_run\": true}"));
+ assertEquals(2, dry.get("would_remove").getAsInt());
+ JsonObject combined = ok(call("POST", "/filter/combine", "{\"filters\": [{\"name\": \"Degree Range\","
+ + " \"params\": {\"range\": [2, 100]}}], \"combine\": \"all\", \"dry_run\": true}"));
+ assertEquals(3, combined.get("nodes_kept").getAsInt());
+ JsonObject col = ok(call("POST", "/filter/apply", "{\"name\": \"Degree Range\","
+ + " \"params\": {\"range\": [2, 100]}, \"action\": \"column\", \"column\": \"busy\"}"));
+ assertEquals("busy", col.get("column").getAsString());
+ assertTrue(ok(call("GET", "/filter/list")).getAsJsonArray("filters").size() > 0);
+ ok(call("POST", "/filter/remove-isolates", "{\"dry_run\": true}"));
+ assertEquals(5, model().getGraph().getNodeCount(), "a dry run changed the graph");
+ }
+
+ @Test
+ void stylingRequestsAreRead() throws Exception {
+ ok(call("POST", "/appearance/partition/color", "{\"column\": \"group\","
+ + " \"colors\": {\"x\": [0, 114, 178], \"y\": [230, 159, 0]}}"));
+ assertEquals(0, model().getGraph().getNode("a").getColor().getRed());
+ assertEquals(230, model().getGraph().getNode("c").getColor().getRed());
+ JsonObject size = ok(call("POST", "/appearance/ranking/size", "{\"column\": \"score\", \"min_size\": 2,"
+ + " \"max_size\": 20, \"cap\": null}"));
+ assertEquals(5.0, size.get("max_value").getAsDouble());
+ assertEquals(20f, model().getGraph().getNode("e").size(), 1e-4);
+ ok(call("POST", "/appearance/ranking/color", "{\"column\": \"score\"}"));
+ ok(call("POST", "/appearance/edge/thickness-by-weight", "{\"min_thickness\": 1, \"max_thickness\": 4}"));
+ ok(call("POST", "/appearance/reset", "{}"));
+ assertEquals(153, model().getGraph().getNode("e").getColor().getRed());
+ assertEquals(20f, model().getGraph().getNode("e").size(), 1e-4,
+ "a colour reset with no size asked for wiped the size ranking");
+ ok(call("POST", "/appearance/reset", "{\"size\": 7}"));
+ assertEquals(7f, model().getGraph().getNode("e").size(), 1e-4);
+ }
+
+ @Test
+ void previewSettingsNestedUnderSettingsAreUnwrapped() throws Exception {
+ JsonObject r = ok(call("POST", "/preview/settings", "{\"settings\": {\"node.label.show\": true,"
+ + " \"node.label.max-char\": 12}}"));
+ assertEquals(2, r.get("properties_set").getAsInt(), r.toString());
+ JsonObject read = ok(call("GET", "/preview/settings")).getAsJsonObject("settings");
+ assertTrue(read.get("node.label.show").getAsBoolean());
+ assertEquals(12, read.get("node.label.max-char").getAsInt());
+ }
+
+ @Test
+ void columnsAndDataLaboratoryRequestsAreRead() throws Exception {
+ ok(call("POST", "/graph/columns/add", "{\"name\": \"rank\", \"type\": \"integer\"}"));
+ JsonArray cols = ok(call("GET", "/graph/columns?target=node")).getAsJsonArray("columns");
+ assertTrue(cols.toString().contains("\"rank\""), cols.toString());
+ ok(call("POST", "/graph/node/attributes", "{\"id\": \"a\", \"attributes\": {\"rank\": 3}}"));
+ JsonObject freq = ok(call("POST", "/datalab/frequencies", "{\"column\": \"group\"}"));
+ assertNotNull(freq);
+ JsonObject partial = ok(call("POST", "/datalab/regex-column", "{\"column\": \"label\","
+ + " \"new_column\": \"starts_ada\", \"regex\": \"^Ada\"}"));
+ assertEquals(0, partial.get("matched").getAsInt());
+ assertTrue(partial.get("warning").getAsString().contains("whole value"), partial.toString());
+ JsonObject whole = ok(call("POST", "/datalab/regex-column", "{\"column\": \"label\","
+ + " \"new_column\": \"is_ada\", \"regex\": \"Ada.*\"}"));
+ assertEquals(1, whole.get("matched").getAsInt());
+ assertFalse(whole.has("warning"));
+ assertEquals(Boolean.TRUE, model().getGraph().getNode("a").getAttribute("is_ada"));
+ }
+
+ @Test
+ void exportsWriteTheFileTheyAreGiven(@TempDir Path dir) throws Exception {
+ JsonObject inline = ok(call("POST", "/export/gexf", "{\"inline\": true, \"visible\": false}"));
+ assertTrue(inline.get("content").getAsString().contains("Ada Fennimore"));
+
+ Path csv = dir.resolve("edges.csv");
+ ok(call("POST", "/export/csv", "{\"file\": " + jsonString(csv) + ", \"target\": \"edges\","
+ + " \"separator\": \";\"}"));
+ assertEquals(5, Files.readAllLines(csv).size());
+
+ Path gml = dir.resolve("g.gml");
+ ok(call("POST", "/export/format", "{\"file\": " + jsonString(gml) + ", \"format\": \"gml\"}"));
+ assertTrue(Files.size(gml) > 0);
+ }
+
+ @Test
+ void workspacesAreListedAndRenamed() throws Exception {
+ JsonArray list = ok(call("GET", "/workspace/list")).getAsJsonArray("workspaces");
+ assertEquals(1, list.size());
+ ok(call("POST", "/workspace/rename", "{\"index\": 0, \"name\": \"Wójcik network\"}"));
+ boolean renamed = false;
+ for (JsonElement w : ok(call("GET", "/workspace/list")).getAsJsonArray("workspaces")) {
+ renamed |= w.toString().contains("Wójcik network");
+ }
+ assertTrue(renamed, "a non-ASCII name did not survive the round trip");
+ }
+
+ private static String jsonString(Path p) {
+ JsonArray a = new JsonArray();
+ a.add(p.toString());
+ return a.get(0).toString();
+ }
+}
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..16d2451c9
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/DataToolsTest.java
@@ -0,0 +1,352 @@
+/*
+ * 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"));
+ }
+
+ /** A visible filter reports the counts Gephi applied, not the ones before it finished. */
+ @Test
+ void visibleFilterReportsTheCountsItApplied() {
+ JsonObject r = service.applyFilter("Degree Range", Map.of("range", List.of(3, 100)), "select", null);
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(3, r.get("nodes_after").getAsInt(), r.toString());
+ assertFalse(r.has("counts_settled"), r.toString());
+ assertEquals(3, gm.getGraphVisible().getNodeCount());
+ }
+
+ /**
+ * Gephi 0.11.4 says when a filter's result is applied. A filter that keeps every node used
+ * to wait out the whole settling budget, because its count never moved.
+ */
+ @Test
+ void filterThatKeepsEveryNodeAnswersAtOnce() {
+ // Warm up first, so the timing below measures the wait and not class loading.
+ service.applyFilter("Degree Range", Map.of("range", List.of(0, 100)), "select", null);
+ service.resetFilters();
+ long started = System.nanoTime();
+ JsonObject r = service.applyFilter("Degree Range", Map.of("range", List.of(0, 100)), "select", null);
+ long ms = (System.nanoTime() - started) / 1_000_000;
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(5, r.get("nodes_after").getAsInt(), r.toString());
+ assertTrue(ms < 1000, "a filter that removes nothing took " + ms + " ms");
+ }
+
+ @Test
+ void combinedFiltersOnTheVisibleGraphAnswerAtOnce() {
+ Map degree3 = Map.of("name", "Degree Range", "params", Map.of("range", List.of(3, 100)));
+ service.applyFilters(List.of(degree3), "all", "select", null, false);
+ service.resetFilters();
+ long started = System.nanoTime();
+ JsonObject r = service.applyFilters(List.of(degree3), "all", "select", null, false);
+ long ms = (System.nanoTime() - started) / 1_000_000;
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(3, r.get("nodes_after").getAsInt(), r.toString());
+ assertTrue(ms < 1000, "combined filters took " + ms + " ms");
+ }
+
+ /** A filter model that reports running for a set number of checks. */
+ private static org.gephi.filters.api.FilterModel runningFor(int checks) {
+ java.util.concurrent.atomic.AtomicInteger left = new java.util.concurrent.atomic.AtomicInteger(checks);
+ return (org.gephi.filters.api.FilterModel) java.lang.reflect.Proxy.newProxyInstance(
+ org.gephi.filters.api.FilterModel.class.getClassLoader(),
+ new Class>[] {org.gephi.filters.api.FilterModel.class},
+ (proxy, method, args) -> "isRunning".equals(method.getName()) ? left.getAndDecrement() > 0 : null);
+ }
+
+ @Test
+ void filterWaitEndsWhenGephiSaysTheResultIsApplied() {
+ assertTrue(GephiControlService.awaitFilterApplied(runningFor(3), 5_000));
+ }
+
+ @Test
+ void filterStillRunningAtTheBudgetIsReportedAsNotSettled() {
+ assertFalse(GephiControlService.awaitFilterApplied(runningFor(Integer.MAX_VALUE), 100));
+ }
+
+ @Test
+ void noFilterModelMeansNothingToWaitFor() {
+ assertTrue(GephiControlService.awaitFilterApplied(null, 100));
+ }
+
+ /**
+ * Resetting filters must leave Gephi's Filters panel showing no active filter, as its own
+ * Filter button does when turned off, or the panel keeps claiming a filter the graph no
+ * longer shows and a later refresh can bring it back.
+ */
+ @Test
+ void resettingFiltersAlsoTurnsOffTheFiltersPanel() {
+ service.applyFilter("Degree Range", Map.of("range", List.of(3, 100)), "select", null);
+ org.gephi.filters.api.FilterModel fm =
+ Lookup.getDefault().lookup(org.gephi.filters.api.FilterController.class).getModel();
+ assertTrue(fm.isFiltering(), "precondition: the filter is active in the panel");
+
+ service.resetFilters();
+ GephiControlService.awaitFilterApplied(fm);
+
+ assertEquals(5, gm.getGraphVisible().getNodeCount(), "the full graph is visible");
+ assertFalse(fm.isFiltering(), "the Filters panel still shows a filter as active");
+ }
+
+ @Test
+ void resettingFiltersAlsoTurnsOffASelectionFilter() {
+ org.gephi.filters.api.FilterController fc =
+ Lookup.getDefault().lookup(org.gephi.filters.api.FilterController.class);
+ org.gephi.filters.spi.FilterBuilder degree = null;
+ for (org.gephi.filters.spi.FilterBuilder b
+ : Lookup.getDefault().lookupAll(org.gephi.filters.spi.FilterBuilder.class)) {
+ if ("Degree Range".equals(b.getName())) {
+ degree = b;
+ }
+ }
+ assertTrue(degree != null, "Gephi's Degree Range filter is available");
+ org.gephi.project.api.Workspace ws = Lookup.getDefault().lookup(ProjectController.class).getCurrentWorkspace();
+ org.gephi.filters.api.Query query = fc.createQuery(degree.getFilter(ws));
+ fc.add(query);
+ fc.selectVisible(query);
+ GephiControlService.awaitFilterApplied(fc.getModel());
+ assertTrue(fc.getModel().isSelecting(), "precondition: the selection filter is active");
+
+ service.resetFilters();
+
+ assertFalse(fc.getModel().isSelecting(), "the Filters panel still shows a selection filter");
+ }
+
+ /**
+ * A filter still being computed when the reset arrives must not come back after it: Gephi's
+ * filter thread can apply its result once the reset has released the graph.
+ */
+ @Test
+ void filterStillComputingWhenResetIsCalledDoesNotComeBack() {
+ org.gephi.filters.api.FilterController fc =
+ Lookup.getDefault().lookup(org.gephi.filters.api.FilterController.class);
+ org.gephi.filters.spi.FilterBuilder degree = null;
+ for (org.gephi.filters.spi.FilterBuilder b
+ : Lookup.getDefault().lookupAll(org.gephi.filters.spi.FilterBuilder.class)) {
+ if ("Degree Range".equals(b.getName())) {
+ degree = b;
+ }
+ }
+ org.gephi.project.api.Workspace ws = Lookup.getDefault().lookup(ProjectController.class).getCurrentWorkspace();
+ for (int round = 0; round < 30; round++) {
+ org.gephi.filters.api.Query query = fc.createQuery(degree.getFilter(ws));
+ fc.add(query);
+ fc.filterVisible(query);
+ service.resetFilters();
+ GephiControlService.awaitFilterApplied(fc.getModel());
+
+ assertFalse(fc.getModel().isFiltering(), "round " + round + ": the filter came back after the reset");
+ assertTrue(gm.getVisibleView().isMainView(), "round " + round + ": the graph is filtered after the reset");
+ }
+ }
+
+ /** A screenshot into a folder that cannot be made says so at once, before asking Gephi. */
+ @Test
+ void screenshotIntoAFolderThatCannotBeMadeIsRefusedAtOnce(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tmp)
+ throws Exception {
+ java.nio.file.Path blocker = java.nio.file.Files.createFile(tmp.resolve("not-a-folder"));
+ long started = System.nanoTime();
+
+ JsonObject r = service.exportScreenshot(blocker.resolve("shot.png").toString(), 1, false);
+
+ assertFalse(r.get("success").getAsBoolean(), r.toString());
+ assertTrue(r.get("error").getAsString().contains("folder"), r.toString());
+ assertTrue((System.nanoTime() - started) / 1_000_000 < 2_000, "it waited for Gephi first");
+ }
+
+ @Test
+ void resetReportsSuccess() {
+ service.applyFilter("Degree Range", Map.of("range", List.of(3, 100)), "select", null);
+
+ JsonObject r = service.resetFilters();
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ }
+
+ /** A view set some other way than through the Filters panel is cleared too. */
+ @Test
+ void resetClearsAViewSetOutsideTheFiltersPanel() {
+ org.gephi.graph.api.GraphView view = gm.createView();
+ Graph sub = gm.getGraph(view);
+ sub.addNode(gm.getGraph().getNode("a"));
+ gm.setVisibleView(view);
+ assertFalse(gm.getVisibleView().isMainView(), "precondition: a subgraph is visible");
+
+ assertTrue(service.resetFilters().get("success").getAsBoolean());
+
+ assertTrue(gm.getVisibleView().isMainView(), "the subgraph view is still visible");
+ assertEquals(5, gm.getGraphVisible().getNodeCount());
+ assertFalse(gm.getGraph().getLock().isWriteLocked(), "the reset left the graph write-locked");
+ }
+
+ /** An export into folders that do not exist yet creates them. */
+ @Test
+ void outputFileCreatesMissingFolders(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tmp) {
+ java.io.File f = GephiControlService.outputFile(tmp.resolve("new/deeper/map.gexf").toString());
+
+ assertTrue(f.getParentFile().isDirectory(), "the folders were not created");
+ assertEquals(tmp.resolve("new/deeper/map.gexf").toFile().getAbsoluteFile(), f);
+ }
+}
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/GenerateGraphTest.java b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GenerateGraphTest.java
new file mode 100644
index 000000000..90a0ad4d0
--- /dev/null
+++ b/modules/GephiAI/src/test/java/org/gephi/plugins/mcp/service/GenerateGraphTest.java
@@ -0,0 +1,265 @@
+/*
+ * 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.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.gson.JsonObject;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+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.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.openide.util.Lookup;
+
+/** Graphs made by Gephi's own generators, loaded the way an imported file is. */
+class GenerateGraphTest {
+
+ private final GephiControlService service = GephiControlService.getInstance();
+
+ @BeforeEach
+ void freshProject() {
+ ProjectController pc = Lookup.getDefault().lookup(ProjectController.class);
+ pc.closeCurrentProject();
+ pc.newProject();
+ }
+
+ /**
+ * The graph's edges as sorted pairs of node positions (the order the generator added them), so
+ * two graphs compare edge for edge. Gephi numbers each generated graph's ids from a counter that
+ * keeps rising, so raw ids differ between two identical graphs.
+ */
+ private static List edgeSet() {
+ Graph g = Lookup.getDefault().lookup(GraphController.class).getGraphModel().getGraph();
+ java.util.Map position = new java.util.HashMap<>();
+ for (Node n : g.getNodes().toArray()) {
+ position.put(n, position.size());
+ }
+ List edges = new ArrayList<>();
+ for (org.gephi.graph.api.Edge e : g.getEdges().toArray()) {
+ int a = position.get(e.getSource());
+ int b = position.get(e.getTarget());
+ edges.add(Math.min(a, b) + "-" + Math.max(a, b));
+ }
+ Collections.sort(edges);
+ return edges;
+ }
+
+ @Test
+ void preferentialAttachmentHasTheModelsEdgeCount() {
+ JsonObject r = service.generateGraph("preferential_attachment", 200, 2, null, 42L);
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(200, r.get("node_count").getAsInt());
+ // A star of m + 1 nodes, then m edges for each later node.
+ assertEquals(2 + (200 - 2 - 1) * 2, r.get("edge_count").getAsInt());
+ assertTrue(r.get("reproducible").getAsBoolean());
+ assertEquals(42L, r.get("seed").getAsLong());
+ }
+
+ @Test
+ void theSameSeedGivesTheSameGraph() {
+ service.generateGraph("preferential_attachment", 300, 3, null, 7L);
+ List first = edgeSet();
+ service.generateGraph("preferential_attachment", 300, 3, null, 7L);
+ List second = edgeSet();
+ service.generateGraph("preferential_attachment", 300, 3, null, 8L);
+ List other = edgeSet();
+
+ assertEquals(first, second);
+ assertNotEquals(first, other);
+ }
+
+ private static org.gephi.graph.api.GraphModel currentModel() {
+ return Lookup.getDefault().lookup(GraphController.class).getGraphModel();
+ }
+
+ /**
+ * Barabasi-Albert and Erdos-Renyi are undirected models. Loaded with Gephi's default edge type
+ * the graph came out directed, which made in-degree, reciprocity and density read wrongly.
+ */
+ @Test
+ void generatedGraphsAreUndirected() {
+ JsonObject ba = service.generateGraph("preferential_attachment", 60, 2, null, 3L);
+ assertTrue(ba.get("success").getAsBoolean(), ba.toString());
+ assertFalse(ba.get("directed").getAsBoolean(), ba.toString());
+ assertTrue(currentModel().isUndirected(), "preferential attachment graph is not undirected");
+ assertEquals(2 + (60 - 2 - 1) * 2, ba.get("edge_count").getAsInt());
+
+ JsonObject er = service.generateGraph("random", 60, null, 0.2, null);
+ assertTrue(er.get("success").getAsBoolean(), er.toString());
+ assertFalse(er.get("directed").getAsBoolean(), er.toString());
+ assertTrue(currentModel().isUndirected(), "random graph is not undirected");
+ }
+
+ @Test
+ void randomGraphSaysItCannotBeReproduced() {
+ JsonObject r = service.generateGraph("random", 100, null, 0.1, null);
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(100, r.get("node_count").getAsInt());
+ assertTrue(r.get("edge_count").getAsInt() > 0 && r.get("edge_count").getAsInt() < 100 * 99 / 2);
+ assertFalse(r.get("reproducible").getAsBoolean());
+ }
+
+ @Test
+ void eachGraphGetsItsOwnWorkspaceNamedAfterTheModel() {
+ service.generateGraph("preferential_attachment", 50, 1, null, 1L);
+ ProjectController pc = Lookup.getDefault().lookup(ProjectController.class);
+ String name = pc.getCurrentWorkspace().getLookup()
+ .lookup(org.gephi.project.api.WorkspaceInformation.class).getName();
+
+ assertTrue(name.startsWith("Preferential attachment"), name);
+ assertTrue(name.contains("seed 1"), name);
+ }
+
+ @Test
+ void badSettingsAreRefusedWithTheReason() {
+ assertTrue(service.generateGraph("small_world", 50, 2, null, null).get("error").getAsString()
+ .contains("preferential_attachment"));
+ assertTrue(service.generateGraph("preferential_attachment", 10, 10, null, null).get("error")
+ .getAsString().contains("edges_per_node"));
+ assertTrue(service.generateGraph("random", 10, null, 1.5, null).get("error").getAsString()
+ .contains("wiring_probability"));
+ assertTrue(service.generateGraph("random", 1, null, 0.5, null).get("error").getAsString()
+ .contains("nodes"));
+ assertTrue(service.generateGraph("random", GephiControlService.GENERATE_MAX_RANDOM_NODES + 1, null, 0.01,
+ null).get("error").getAsString().contains("nodes"));
+ }
+
+ /** One request must not be able to run Gephi out of memory. */
+ @Test
+ void preferentialAttachmentRefusesAnEdgeCountOverTheCap() {
+ JsonObject r = service.generateGraph("preferential_attachment", 100_000, 50_000, null, 1L);
+
+ assertFalse(r.get("success").getAsBoolean());
+ String error = r.get("error").getAsString();
+ String cap = String.valueOf(GephiControlService.GENERATE_MAX_EXPECTED_EDGES);
+ assertTrue(error.contains("edges") && error.contains(cap), error);
+ }
+
+ @Test
+ void preferentialAttachmentRefusesTooManyNodes() {
+ JsonObject r = service.generateGraph("preferential_attachment", GephiControlService.GENERATE_MAX_NODES + 1, 2,
+ null, 1L);
+
+ assertTrue(r.get("error").getAsString().contains("nodes"), r.toString());
+ }
+
+ @Test
+ void randomGraphRefusesAnExpectedEdgeCountOverTheCap() {
+ JsonObject r = service.generateGraph("random", 20_000, null, 0.5, null);
+
+ assertTrue(r.get("error").getAsString().contains("expects about"), r.toString());
+ }
+
+ private static int workspaceCount() {
+ return Lookup.getDefault().lookup(ProjectController.class).getCurrentProject().getWorkspaces().size();
+ }
+
+ /** An empty workspace the person named is theirs: generating a graph must not delete it. */
+ @Test
+ void anEmptyWorkspaceThePersonNamedIsKept() {
+ ProjectController pc = Lookup.getDefault().lookup(ProjectController.class);
+ pc.renameWorkspace(pc.getCurrentWorkspace(), "Fieldwork 2026");
+ int before = workspaceCount();
+
+ service.generateGraph("preferential_attachment", 50, 1, null, 1L);
+
+ assertEquals(before + 1, workspaceCount(), "the named empty workspace was deleted");
+ }
+
+ /** An empty workspace with Gephi's default name is still tidied away, as before. */
+ @Test
+ void anEmptyDefaultWorkspaceIsStillRemoved() {
+ int before = workspaceCount();
+
+ service.generateGraph("preferential_attachment", 50, 1, null, 1L);
+
+ assertEquals(before, workspaceCount(), "the empty default workspace was left behind");
+ }
+
+ @Test
+ void preferentialAttachmentUsesTheEdgesPerNodeAsked() {
+ JsonObject r = service.generateGraph("barabasi_albert", 100, 3, null, 5L);
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(3 + (100 - 3 - 1) * 3, r.get("edge_count").getAsInt());
+ assertEquals("preferential_attachment", r.get("model").getAsString());
+ assertEquals(3, r.get("edges_per_node").getAsInt());
+ assertTrue(r.get("workspace").getAsString().startsWith("Preferential attachment (100 nodes, 3 per node"),
+ r.toString());
+ }
+
+ /** A wiring probability of 1 links every pair, so the probability asked for is the one used. */
+ @Test
+ void randomGraphUsesTheProbabilityAsked() {
+ JsonObject r = service.generateGraph("erdos_renyi", 30, null, 1.0, null);
+
+ assertTrue(r.get("success").getAsBoolean(), r.toString());
+ assertEquals(30 * 29 / 2, r.get("edge_count").getAsInt());
+ assertEquals("random", r.get("model").getAsString());
+ assertEquals(1.0, r.get("wiring_probability").getAsDouble());
+ assertTrue(r.get("note").getAsString().contains("no seed"), r.toString());
+ assertTrue(r.get("workspace").getAsString().startsWith("Random graph (30 nodes"), r.toString());
+ }
+
+ @Test
+ void preferentialAttachmentLimitsAreInclusive() {
+ assertNull(GephiControlService.preferentialAttachmentRefusal(2, 1), "the smallest graph");
+ assertNull(GephiControlService.preferentialAttachmentRefusal(GephiControlService.GENERATE_MAX_NODES, 1),
+ "exactly the node cap");
+ assertTrue(GephiControlService.preferentialAttachmentRefusal(1, 1).contains("nodes"));
+ assertNull(GephiControlService.preferentialAttachmentRefusal(10, 9), "edges_per_node one below nodes");
+ assertTrue(GephiControlService.preferentialAttachmentRefusal(10, 0).contains("edges_per_node"));
+ }
+
+ /** 40 + (50,040 - 41) x 40 is exactly 2,000,000 edges: allowed; one node more is not. */
+ @Test
+ void preferentialAttachmentEdgeCapIsExact() {
+ assertNull(GephiControlService.preferentialAttachmentRefusal(50_040, 40));
+ String over = GephiControlService.preferentialAttachmentRefusal(50_041, 40);
+
+ assertTrue(over != null && over.contains("has 2000040 edges"), over);
+ }
+
+ @Test
+ void randomGraphLimitsAreInclusive() {
+ assertNull(GephiControlService.randomGraphRefusal(2, 1.0), "two nodes, every pair linked");
+ assertNull(GephiControlService.randomGraphRefusal(GephiControlService.GENERATE_MAX_RANDOM_NODES, 1e-6),
+ "exactly the node cap");
+ assertTrue(GephiControlService.randomGraphRefusal(2, 0.0).contains("wiring_probability"));
+ }
+
+ /** 2,001 nodes have 2,001,000 pairs; this probability expects exactly 2,000,000 edges. */
+ @Test
+ void randomGraphExpectedEdgeCapIsExact() {
+ double atCap = 0.9995002498750625;
+
+ assertNull(GephiControlService.randomGraphRefusal(2001, atCap));
+ String over = GephiControlService.randomGraphRefusal(2001, Math.nextUp(atCap));
+ assertTrue(over != null && over.contains("expects about 2000000 edges"), over);
+ }
+}
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