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:
Safi
2026-05-31 16:41:18 +01:00
co-authored by Claude Sonnet 4.6
parent c898dc62cd
commit 690b4e5d3e
2 changed files with 92 additions and 2 deletions
+19 -2
View File
@@ -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:
+73
View File
@@ -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"]