mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
Fix HTML entity encoding in graph tooltips
(cherry picked from commit 88025d0a93)
This commit is contained in:
1 parent
9a06e95408
commit
785ee5a426
2 files changed
+76
-3
No files matched your search
@@ -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},
|
||||
|
||||
@@ -432,6 +432,13 @@ def _vis_nodes_from_html(content: str) -> list:
|
||||
return json.loads(m.group(1).replace("<\\/", "</"))
|
||||
|
||||
|
||||
def _vis_edges_from_html(content: str) -> 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("<\\/", "</"))
|
||||
|
||||
|
||||
def test_to_html_annotated_node_gets_learning_status_and_ring():
|
||||
"""A node with an overlay entry gets learning_status + learning_stale fields,
|
||||
a status-colored ring (border), and a Lesson line in its hover title."""
|
||||
@@ -494,6 +501,72 @@ def test_to_html_unannotated_identical_to_pre_feature():
|
||||
assert "learning_status" not in ca
|
||||
|
||||
|
||||
def test_to_html_tooltips_preserve_special_characters_without_html_entities(tmp_path):
|
||||
"""Issue #3664: Node and edge hover tooltips (vis-network title) must preserve
|
||||
raw characters (&, ', ", <, >) 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='<Widget prop="value" & count > 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 <indirect>", 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"] == '<Widget prop="value" & count > 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 <indirect> [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 <Pipeline>", 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 <Pipeline>\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()
|
||||
|
||||
Reference in new issue
Block a user