From 690b4e5d3e896a97d844cee664a0c4b7872cfc55 Mon Sep 17 00:00:00 2001 From: Safi Date: Sun, 31 May 2026 16:41:18 +0100 Subject: [PATCH] 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 --- graphify/export.py | 21 ++++++++- tests/test_obsidian_filename_cap.py | 73 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/test_obsidian_filename_cap.py diff --git a/graphify/export.py b/graphify/export.py index ff127c0b..6a6fec8f 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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: diff --git a/tests/test_obsidian_filename_cap.py b/tests/test_obsidian_filename_cap.py new file mode 100644 index 00000000..2d7f1c85 --- /dev/null +++ b/tests/test_obsidian_filename_cap.py @@ -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"]