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: