Skip to content

to_obsidian: case-only filename collisions silently overwrite node notes on case-insensitive filesystems #2650

Open

Description

@sergeville

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] = base

Two 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] = base
PROPOSED  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

  • graphifyy 0.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

  1. ayushcodes10 commented on Sep 14, 2026

    @ayushcodes10
    Contributor

    Already fixed on current v8. to_obsidian's filename-dedup logic has since been refactored into a shared _dedup_node_filenames helper (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:

    from graphify.export import _dedup_node_filenames, _obsidian_safe_stem
    import networkx as nx
    
    G = nx.Graph()
    G.add_node('n1', label='apianalyze Endpoint')
    G.add_node('n2', label='apianalyze endpoint')
    G.add_node('n3', label='SprintSelector Component')
    G.add_node('n4', label='SprintSelector component')
    
    result = _dedup_node_filenames(G, lambda l: _obsidian_safe_stem(l, 200))
    # {'n1': 'apianalyze Endpoint', 'n2': 'apianalyze endpoint_1',
    #  'n3': 'SprintSelector Component', 'n4': 'SprintSelector component_1'}

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions