mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-20 22:31:48 +00:00
cap obsidian/canvas filenames to avoid ENAMETOOLONG on long labels (fixes #1094)
to_obsidian and to_canvas built note filenames from node labels with no length cap, so a label >=255 bytes crashed write_text with OSError. Add a shared _cap_filename helper that caps on UTF-8 bytes (not chars, so CJK labels don't slip past) and appends an 8-char hash of the full label when truncating, so two distinct labels sharing a long prefix stay distinct. Both safe_name builders route node, community and canvas filenames through it; wikilinks stay consistent because they read the same filename dict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c898dc62cd
commit
690b4e5d3e
+19
-2
@@ -814,6 +814,23 @@ def to_html(
|
||||
generate_html = to_html
|
||||
|
||||
|
||||
def _cap_filename(s: str, limit: int = 200) -> str:
|
||||
"""Cap a filename stem to ``limit`` UTF-8 bytes so it stays under the 255-byte
|
||||
filesystem limit even after the ``.md`` extension and dedup suffix are added
|
||||
(#1094). The cap is on BYTES, not chars, because a label of multibyte
|
||||
characters (CJK, accented) can exceed 255 bytes well under 255 chars. When
|
||||
truncation happens, an 8-char hash of the full label is appended so two
|
||||
distinct labels sharing a long prefix produce distinct, deterministic
|
||||
filenames instead of colliding."""
|
||||
b = s.encode("utf-8")
|
||||
if len(b) <= limit:
|
||||
return s
|
||||
digest = hashlib.sha1(s.encode("utf-8")).hexdigest()[:8] # nosec - not security
|
||||
keep = limit - 9 # "_" + 8 hex chars
|
||||
truncated = b[:keep].decode("utf-8", "ignore") # "ignore" drops a split trailing char
|
||||
return f"{truncated}_{digest}"
|
||||
|
||||
|
||||
def to_obsidian(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
@@ -840,7 +857,7 @@ def to_obsidian(
|
||||
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
|
||||
# Strip trailing .md/.mdx/.markdown so "CLAUDE.md" doesn't become "CLAUDE.md.md"
|
||||
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
|
||||
return cleaned or "unnamed"
|
||||
return _cap_filename(cleaned) if cleaned else "unnamed"
|
||||
|
||||
node_filename: dict[str, str] = {}
|
||||
seen_names: dict[str, int] = {}
|
||||
@@ -1080,7 +1097,7 @@ def to_canvas(
|
||||
def safe_name(label: str) -> str:
|
||||
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
|
||||
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
|
||||
return cleaned or "unnamed"
|
||||
return _cap_filename(cleaned) if cleaned else "unnamed"
|
||||
|
||||
# Build node_filenames if not provided (same dedup logic as to_obsidian)
|
||||
if node_filenames is None:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Regression tests for issue #1094: to_obsidian / to_canvas must cap filenames
|
||||
to stay under the 255-byte filesystem limit, instead of crashing with
|
||||
OSError ENAMETOOLONG on long node labels."""
|
||||
import networkx as nx
|
||||
|
||||
from graphify.export import to_obsidian, to_canvas
|
||||
|
||||
|
||||
def _graph(labels: list[str]) -> tuple[nx.Graph, dict[int, list[str]]]:
|
||||
G = nx.Graph()
|
||||
ids = []
|
||||
for i, lab in enumerate(labels):
|
||||
nid = f"n{i}"
|
||||
G.add_node(nid, label=lab, file_type="code", source_file="x.py", community=0)
|
||||
ids.append(nid)
|
||||
# chain them so each note has at least one wikilink
|
||||
for a, b in zip(ids, ids[1:]):
|
||||
G.add_edge(a, b, relation="calls", confidence="EXTRACTED")
|
||||
return G, {0: ids}
|
||||
|
||||
|
||||
def _max_name_bytes(out_dir) -> int:
|
||||
return max(len(p.name.encode("utf-8")) for p in out_dir.glob("*.md"))
|
||||
|
||||
|
||||
def test_obsidian_long_ascii_label_does_not_crash(tmp_path):
|
||||
G, comms = _graph(["a" * 300, "short"])
|
||||
to_obsidian(G, comms, str(tmp_path))
|
||||
assert _max_name_bytes(tmp_path) <= 255
|
||||
|
||||
|
||||
def test_obsidian_long_cjk_label_byte_cap(tmp_path):
|
||||
# 200 CJK chars = 600 bytes in UTF-8: a char cap would still overflow.
|
||||
G, comms = _graph(["中" * 300, "ok"])
|
||||
to_obsidian(G, comms, str(tmp_path))
|
||||
assert _max_name_bytes(tmp_path) <= 255
|
||||
|
||||
|
||||
def test_obsidian_distinct_long_labels_sharing_prefix_do_not_collide(tmp_path):
|
||||
prefix = "z" * 250
|
||||
G, comms = _graph([prefix + "_ALPHA", prefix + "_BETA"])
|
||||
to_obsidian(G, comms, str(tmp_path))
|
||||
md_files = [p for p in tmp_path.glob("*.md") if not p.name.startswith("_COMMUNITY_")]
|
||||
# Two distinct nodes must produce two distinct files (no overwrite).
|
||||
assert len(md_files) == 2, [p.name for p in md_files]
|
||||
assert _max_name_bytes(tmp_path) <= 255
|
||||
|
||||
|
||||
def test_obsidian_wikilink_resolves_after_truncation(tmp_path):
|
||||
long_label = "w" * 300
|
||||
G, comms = _graph([long_label, "neighbor"])
|
||||
to_obsidian(G, comms, str(tmp_path))
|
||||
|
||||
# The note for "neighbor" should link to the truncated filename of long_label.
|
||||
neighbor_note = (tmp_path / "neighbor.md").read_text()
|
||||
# Extract the [[target]] from the neighbor's Connections section.
|
||||
import re
|
||||
targets = re.findall(r"\[\[([^\]]+)\]\]", neighbor_note)
|
||||
assert targets, "no wikilink found in neighbor note"
|
||||
# Every linked target must correspond to a real .md file on disk.
|
||||
for t in targets:
|
||||
assert (tmp_path / f"{t}.md").exists(), f"dangling wikilink: {t}"
|
||||
|
||||
|
||||
def test_canvas_long_label_file_ref_capped(tmp_path):
|
||||
import json
|
||||
G, comms = _graph(["c" * 300, "ok"])
|
||||
out = tmp_path / "graph.canvas"
|
||||
to_canvas(G, comms, str(out))
|
||||
data = json.loads(out.read_text())
|
||||
for node in data.get("nodes", []):
|
||||
if node.get("type") == "file":
|
||||
assert len(node["file"].encode("utf-8")) <= 255, node["file"]
|
||||
Reference in New Issue
Block a user