mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-21 04:56:18 +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:
|
||||
|
||||
Reference in New Issue
Block a user