diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 62ba5b2a8..cc9df1584 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -519,7 +519,7 @@ def to_html( "color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}}, "size": round(size, 1), "font": {"size": font_size, "color": "#ffffff"}, - "title": _html.escape(label), + "title": label, "community": cid, "community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")), "source_file": sanitize_label(str(data.get("source_file") or "")), @@ -555,7 +555,7 @@ def to_html( lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)" if stale: lesson += " [code changed — re-verify]" - node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson)) + node["title"] = f"{label}\n{sanitize_label(lesson)}" vis_nodes.append(node) # Build edges list. Restore original edge direction from _src/_tgt @@ -572,7 +572,7 @@ def to_html( "from": true_src, "to": true_tgt, "label": relation, - "title": _html.escape(f"{relation} [{confidence}]"), + "title": sanitize_label(f"{relation} [{confidence}]"), "dashes": confidence != "EXTRACTED", "width": 2 if confidence == "EXTRACTED" else 1, "color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35}, diff --git a/tests/test_export.py b/tests/test_export.py index d957b8795..971e85e28 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -432,6 +432,13 @@ def _vis_nodes_from_html(content: str) -> list: return json.loads(m.group(1).replace("<\\/", " list: + """Extract the RAW_EDGES JSON array embedded in the generated HTML.""" + m = re.search(r"const RAW_EDGES = (\[.*?\]);", content, re.DOTALL) + assert m, "RAW_EDGES not found in HTML" + return json.loads(m.group(1).replace("<\\/", ") without HTML entity escaping (&, ', etc.) + because vis-network renders title via innerText rather than innerHTML.""" + import networkx as nx + G = nx.Graph() + G.add_node("n1", label="B-Roll & Filming", source_file="src/a.py", community=0) + G.add_node("n2", label="Anna's content batching schedule", source_file="src/b.py", community=0) + G.add_node("n3", label=' 0>', source_file="src/c.py", community=0) + G.add_edge("n1", "n2", relation="reviews & approves", confidence="EXTRACTED") + G.add_edge("n2", "n3", relation="calls ", confidence="INFERRED") + + labels = {0: "Team A & Team B"} + out = tmp_path / "graph.html" + to_html(G, {0: ["n1", "n2", "n3"]}, str(out), community_labels=labels) + content = out.read_text(encoding="utf-8") + + nodes = {n["id"]: n for n in _vis_nodes_from_html(content)} + assert nodes["n1"]["title"] == "B-Roll & Filming" + assert "&" not in nodes["n1"]["title"] + + assert nodes["n2"]["title"] == "Anna's content batching schedule" + assert "'" not in nodes["n2"]["title"] + assert "'" not in nodes["n2"]["title"] + + assert nodes["n3"]["title"] == ' 0>' + for entity in ("<", ">", """, "&"): + assert entity not in nodes["n3"]["title"] + + edges = _vis_edges_from_html(content) + edge_map = {(e["from"], e["to"]): e for e in edges} + e1 = edge_map[("n1", "n2")] + assert e1["title"] == "reviews & approves [EXTRACTED]" + assert "&" not in e1["title"] + + e2 = edge_map[("n2", "n3")] + assert e2["title"] == "calls [INFERRED]" + assert "<" not in e2["title"] + assert ">" not in e2["title"] + + # In contrast, legend items are injected into innerHTML and MUST remain HTML-escaped. + assert "Team A & Team B" in content + + +def test_to_html_learning_overlay_tooltip_preserves_special_characters(tmp_path): + """Issue #3664: Node with learning overlay preserves special characters in both + the label and lesson text without HTML entity escaping.""" + import networkx as nx + G = nx.Graph() + G.add_node("n1", label="Anna's & Bob's ", source_file="src/a.py", community=0) + overlay = { + "n1": {"status": "contested", "uses": 2, "neg": 1, "stale": True} + } + + out = tmp_path / "graph.html" + to_html(G, {0: ["n1"]}, str(out), learning_overlay=overlay) + content = out.read_text(encoding="utf-8") + + nodes = {n["id"]: n for n in _vis_nodes_from_html(content)} + title = nodes["n1"]["title"] + assert "Anna's & Bob's \n" in title + assert "Lesson: contested (useful 2 / dead-end 1) [code changed — re-verify]" in title + for entity in ("&", "'", "'", "<", ">"): + assert entity not in title + + def test_to_canvas_file_paths_relative_to_vault(): """Node file paths in canvas must be vault-root-relative (just fname.md), not hardcoded.""" G = make_graph()