diff --git a/CHANGELOG.md b/CHANGELOG.md index 03d569da..d76310f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.3.12 (2026-04-07) + +- Fix: `sanitize_label` was double-encoding HTML entities in the interactive graph (`&lt;` instead of `<`) — removed `html.escape()` from `sanitize_label`; callers that inject directly into HTML now call `html.escape()` themselves (#66) +- Fix: `--wiki` flag missing from `skill.md` usage table (#55) + ## 0.3.11 (2026-04-07) - Fix: Louvain fallback hangs indefinitely on large sparse graphs — added `max_level=10, threshold=1e-4` to prevent infinite loops while preserving community quality (#48) diff --git a/graphify/__main__.py b/graphify/__main__.py index c7c3cac0..2b3f3b56 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -382,7 +382,9 @@ def main() -> None: if len(sys.argv) < 3: print("Usage: graphify query \"\" [--dfs] [--budget N] [--graph path]", file=sys.stderr) sys.exit(1) - from graphify.serve import _load_graph, _score_nodes, _bfs, _dfs, _subgraph_to_text + from graphify.serve import _score_nodes, _bfs, _dfs, _subgraph_to_text + from graphify.security import sanitize_label + from networkx.readwrite import json_graph question = sys.argv[2] use_dfs = "--dfs" in sys.argv budget = 2000 @@ -391,14 +393,39 @@ def main() -> None: i = 0 while i < len(args): if args[i] == "--budget" and i + 1 < len(args): - budget = int(args[i + 1]); i += 2 + try: + budget = int(args[i + 1]) + except ValueError: + print(f"error: --budget must be an integer", file=sys.stderr) + sys.exit(1) + i += 2 elif args[i].startswith("--budget="): - budget = int(args[i].split("=", 1)[1]); i += 1 + try: + budget = int(args[i].split("=", 1)[1]) + except ValueError: + print(f"error: --budget must be an integer", file=sys.stderr) + sys.exit(1) + i += 1 elif args[i] == "--graph" and i + 1 < len(args): graph_path = args[i + 1]; i += 2 else: i += 1 - G = _load_graph(graph_path) + # Load graph directly — validate_graph_path restricts to graphify-out/ + # so for custom --graph paths we resolve and load directly after existence check + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + if not gp.suffix == ".json": + print(f"error: graph file must be a .json file", file=sys.stderr) + sys.exit(1) + try: + import json as _json + import networkx as _nx + G = json_graph.node_link_graph(_json.loads(gp.read_text(encoding="utf-8")), edges="links") + except Exception as exc: + print(f"error: could not load graph: {exc}", file=sys.stderr) + sys.exit(1) terms = [t.lower() for t in question.split() if len(t) > 2] scored = _score_nodes(G, terms) if not scored: diff --git a/graphify/export.py b/graphify/export.py index fe0cb592..2870fdcc 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -1,5 +1,6 @@ # write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher from __future__ import annotations +import html as _html import json import math import re @@ -371,7 +372,7 @@ def to_html( edges_json = json.dumps(vis_edges) legend_json = json.dumps(legend_data) hyperedges_json = json.dumps(getattr(G, "graph", {}).get("hyperedges", [])) - title = sanitize_label(str(output_path)) + title = _html.escape(sanitize_label(str(output_path))) stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities" html = f""" diff --git a/graphify/security.py b/graphify/security.py index 1b8ff846..8163805b 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -186,13 +186,12 @@ _MAX_LABEL_LEN = 256 def sanitize_label(text: str) -> str: - """Strip control characters, cap length, then HTML-escape. + """Strip control characters and cap length. - Applied to all node labels and edge titles before they are embedded - in pyvis HTML output or returned via the MCP server, preventing both - XSS and broken visualisations from malformed source identifiers. + Safe for embedding in JSON data (inside