Repository navigation
to_obsidian: case-only filename collisions silently overwrite node notes on case-insensitive filesystems #2650
Description
Case-only filename collisions overwrite each other
In export.py::to_obsidian(), the filename dedup map is keyed on the exact-case name (v0.8.37, lines 867-876):
node_filename: dict[str, str] = {}
seen_names: dict[str, int] = {}
for node_id, data in G.nodes(data=True):
base = safe_name(data.get("label", node_id))
if base in seen_names: # <-- case-SENSITIVE membership test
seen_names[base] += 1
node_filename[node_id] = f"{base}_{seen_names[base]}"
else:
seen_names[base] = 0
node_filename[node_id] = baseTwo nodes whose labels differ only in case produce two distinct keys, so no _1 suffix is added — but on a case-insensitive filesystem they resolve to the same path, and the second write_text silently overwrites the first. One node's note is lost, and the [[wikilink]] to it from other notes points at a file whose on-disk casing came from whichever node wrote last.
Note that safe_name() strips / and :, which increases the collision rate — api/analyze Endpoint and api:analyze endpoint both collapse toward the same stem.
Observed
7 collisions in one 1,729-node export:
| Node A | Node B |
|---|---|
apianalyze Endpoint |
apianalyze endpoint |
SprintSelector Component |
SprintSelector component |
workflowclaude Context Key |
workflowclaude context key |
brainstorm_sessions Table |
brainstorm_sessions table |
idea-flow Service (3026) |
idea-flow service (3026) |
Lifecycle Spine And Runtime Truth Contract |
Lifecycle Spine and Runtime Truth Contract |
workflow Shared-Context Heartbeat |
workflow shared-context heartbeat |
The output directory holds 1,899 .md files against 1,729 nodes + 177 community notes = 1,906 expected — i.e. 7 fewer files than expected. That shortfall is exactly the collision count above, which is what silent overwrite looks like from the outside.
Minimal reproduction
Four nodes, two case-only pairs, run through the exact safe_name + dedup logic above:
CURRENT filenames=['apianalyze Endpoint', 'apianalyze endpoint',
'SprintSelector Component', 'SprintSelector component']
files on disk = 2 of 4 nodes -> 2 LOST
unresolved links: ['apianalyze endpoint', 'SprintSelector component']
Suggested fix — verified against the same reproduction
Key the dedup on the casefolded name while keeping the original for display:
key = base.casefold()
if key in seen_names:
seen_names[key] += 1
node_filename[node_id] = f"{base}_{seen_names[key]}"
else:
seen_names[key] = 0
node_filename[node_id] = basePROPOSED filenames=['apianalyze Endpoint', 'apianalyze endpoint_1',
'SprintSelector Component', 'SprintSelector component_1']
files on disk = 4 of 4 nodes -> 0 LOST
unresolved links: []
This is also correct on case-sensitive filesystems — it just adds a suffix where Linux would have allowed two distinct files. If that's undesirable, gate it behind a platform check or an --assume-case-insensitive-fs flag; defaulting to safe seems better than defaulting to silent loss.
Severity
Low in practice for Obsidian users: Obsidian resolves wikilinks case-insensitively, so the surviving file still answers links aimed at the lost one. In the export above, 0 of 1,799 distinct link targets is genuinely unresolved — the loss is completely invisible from inside the app. That is the argument for fixing it rather than against: seven nodes have no note and nothing anywhere surfaces it.
Environment
graphifyy0.8.37, Python 3.11- macOS 26.6.1 (Darwin 25.6.0), APFS — case-insensitivity confirmed empirically, not assumed
- Graph: 1,729 nodes / 2,077 edges / 177 communities
Activity
ayushcodes10 commentedon Sep 14, 2026
Already fixed on current
v8.to_obsidian's filename-dedup logic has since been refactored into a shared_dedup_node_filenameshelper (graphify/export.py), and its docstring documents this exact fix: the collision set is keyed on the lowercased candidate, so two labels differing only by case get distinct filenames instead of silently colliding on a case-insensitive filesystem.Verified directly with the issue's own repro:
All 4 nodes get distinct filenames — 0 lost, matching the "PROPOSED" outcome in the issue's own writeup. Given this reproduces as fixed, I don't think there's anything left to change here.