Skip to content

Fix case-only filename collisions silently overwriting Obsidian/Canvas/Wiki notes#1457

Closed
TPAteeq wants to merge 4 commits into
Graphify-Labs:v8from
TPAteeq:fix-obsidian-canvas-case-collision
Closed

Fix case-only filename collisions silently overwriting Obsidian/Canvas/Wiki notes#1457
TPAteeq wants to merge 4 commits into
Graphify-Labs:v8from
TPAteeq:fix-obsidian-canvas-case-collision

Conversation

@TPAteeq

@TPAteeq TPAteeq commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #1453.

Three exporters derive on-disk filenames from node/community labels and
dedup them, but keyed the dedup on the exact-case name. On
case-insensitive filesystems — macOS/APFS and Windows/NTFS, the two most
common desktop targets — two labels that differ only by case (e.g. a class
References and a markdown heading references) are distinct keys but
resolve to the same path on disk, so the second write_text() silently
overwrites the first: no numeric suffix, no warning, no signal in the
return value. Same data-loss class as #497.

The reporter confirmed it empirically: a 3,288-node graph wrote 3,366 files
against an expected 3,489 — 123 notes silently lost.

Fix

  • to_obsidian / to_canvas (graphify/export.py): the node-note
    dedup now keys a used set on base.lower() while still emitting the
    original-case filename, so a real collision gets a numeric suffix. The
    dedup was duplicated verbatim across both functions (the issue flagged the
    drift risk), so it's extracted into one shared _dedup_node_filenames()
    helper.
  • to_wiki (graphify/wiki.py): _unique_slug had the same
    case-sensitive slug in used_slugs membership (the wiki.py: _safe_filename(label) collisions cause silent article overwrites #497 dedup didn't fold
    case); it now folds case the same way.
  • Review-driven hardening (same PR):
    • to_obsidian's _COMMUNITY_*.md overview notes had no dedup at all,
      so two community labels differing only by case — or even identical after
      sanitizing — clobbered each other. One case-folded-deduped filename per
      community is now computed up front and used for both the write and the
      intra-vault [[_COMMUNITY_...]] cross-references so they stay consistent.
    • The generated suffix is itself re-checked, so a synthesized base_1
      can't silently overwrite a node whose literal label is already base_1
      (this collides on case-sensitive filesystems too).

Testing

  • Regression tests in tests/test_export.py and tests/test_wiki.py,
    robust on both filesystem types: each asserts every note lands on disk
    (catches the overwrite on case-insensitive FS) and that no two stems
    collide under .lower() (catches the missing suffix on case-sensitive FS).
    Coverage: obsidian/canvas case collision, literal-suffix collision,
    community-note collision, obsidian↔canvas filename agreement (the real CLI
    path calls them separately), wiki community↔community and god-node↔community
    collisions. Each new test verified red on the pre-fix code, green after.
  • Full test_export.py / test_wiki.py plus the adjacent
    obsidian/canvas/CLI/pipeline suites pass (99 tests).
  • Verified end-to-end in Obsidian: a graph with References/references
    and Parser/parser/PARSER produces five distinct notes
    (References, references_1, Parser, parser_1, PARSER_2) and a
    canvas whose cards each resolve to a real file.

Out of scope (follow-ups)

  • report.py's GRAPH_REPORT.md emits its own [[_COMMUNITY_...]] hub
    links via a separate sanitizer; for a suffixed (case-colliding) community
    its link can dangle. Pre-existing cross-tool coupling — the vault's own
    notes and internal links are now correct and no longer lose data.
  • transcribe.py writes {stem}.txt transcripts with no dedup, so files
    from different dirs with case-only-distinct stems can overwrite. Separate,
    pre-existing, not label-keyed — maybe worth its own issue.

TPAteeq and others added 4 commits June 25, 2026 14:08
…s notes

to_obsidian/to_canvas keyed their filename-dedup map on the exact-case
name, so two labels differing only by case (e.g. a class `References` and
a prose heading `references`) counted as non-colliding. On case-insensitive
filesystems (macOS/APFS, Windows/NTFS) the second write_text then clobbered
the first note on disk with no suffix and no warning.

Key the dedup map on the lowercased name while still emitting the
original-case filename, so any collision that would actually collide on
disk gets a numeric suffix. Extract the dedup (duplicated verbatim across
both exporters) into a shared _dedup_node_filenames helper so the two
can't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wiki.py's _unique_slug deduped article filenames with a case-sensitive
set membership (`slug in used_slugs`), so two community or god-node labels
differing only by case (e.g. "Parser" vs "parser") were distinct entries
and both wrote to disk — the second clobbering the first on case-insensitive
filesystems. Same bug class as the to_obsidian/to_canvas fix and the Graphify-Labs#497
dedup it descends from.

Fold case in the membership check (key the set on the lowercased slug) while
still emitting the original-case filename, so a real collision gets a
numeric suffix. Add a regression test that fails on both filesystem types
without the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review of the case-fold fix surfaced two residual overwrite paths:

- _dedup_node_filenames assigned a `base_N` suffix but never re-checked or
  registered it, so a generated `base_1` could still silently overwrite a
  node whose literal label was already `base_1` (and the case-fold widened
  that window). The helper now loops and registers each emitted name, like
  wiki's _unique_slug.

- to_obsidian's `_COMMUNITY_*.md` overview notes had no dedup at all — two
  community labels differing only by case (or even identical after
  sanitizing) clobbered each other. Compute one case-folded-deduped
  filename per community up front and use it for both the write and the
  intra-vault [[_COMMUNITY_...]] cross-references so they stay consistent.

Add regression tests: a literal-suffix collision (fails on case-sensitive
filesystems too), community-note case collision, and an obsidian/canvas
cross-exporter filename-agreement guard. Plus a wiki god-node/community
cross-case collision test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
safishamsi pushed a commit that referenced this pull request Jun 25, 2026
…rite (#1453)

to_obsidian / to_canvas / to_wiki keyed filename dedup on the exact-case name,
so two labels differing only by case (e.g. `References` vs `references`) counted
as non-colliding and the second write clobbered the first on case-insensitive
filesystems (macOS/APFS, Windows/NTFS) — silently, no suffix, no warning.

Dedup now folds case (keyed on the lowercased name) while emitting the
original-case filename, so any pair that would collide on disk gets a numeric
suffix. The obsidian/canvas dedup is one shared helper (`_dedup_node_filenames`)
so they can't drift; wiki's slug dedup gets the matching fix; the `_COMMUNITY_*`
overview notes (which had no dedup at all) are covered; and a generated `base_1`
is re-checked so it can't overwrite a node literally labelled `base_1`.

Ported from PR #1457 by @TPAteeq onto current v8. Verified with a rigorous
edge-case battery (case-only collision, base_1 literal re-check -> base_1_1,
community-label case fold, determinism) plus the PR's tests; full suite 2404
passed, ruff + skillgen clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@safishamsi

Copy link
Copy Markdown
Collaborator

Landed on v8 (commit 5d63aad), authored to you. Verified with a rigorous edge-case battery beyond the included tests: case-only collision (References + references_1), the base_1 literal re-check (a synthesized base_1 becomes base_1_1 rather than clobbering a node literally named base_1), community-label case folding (_COMMUNITY_API + _COMMUNITY_api_1), and determinism across runs. Full suite 2404 passed, ruff + skillgen clean. Nicely reasoned fix — the shared helper and the suffix re-check are exactly right. Closing as landed.

@safishamsi safishamsi closed this Jun 25, 2026
safishamsi pushed a commit that referenced this pull request Jun 25, 2026
…id (#1452)

to_canvas sized each community group box for a ceil(sqrt(n))-column grid but the
placement loop hardcoded 3 columns, so any community bigger than ~9 members
rendered as a cramped 3-wide strip in an over-wide, mostly-empty box (and the box
width/height didn't even agree — w used sqrt(n), h used /3). The column count is
now computed once per community (inner_cols) and reused for box width, box height,
and card placement, so the cards fill the box. Cosmetic, no data change.

Ported from PR #1459 by @TPAteeq onto current v8 (clean: only the grid math
changed, the #1457 dedup helper is untouched). Verified the geometry on a real
canvas: n=25 -> 5x5 grid with every card inside its box; n=10 -> 4 columns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

to_obsidian()/to_canvas(): case-sensitive seen_names dedup silently overwrites files on case-insensitive filesystems

2 participants