Fix case-only filename collisions silently overwriting Obsidian/Canvas/Wiki notes#1457
Closed
TPAteeq wants to merge 4 commits into
Closed
Fix case-only filename collisions silently overwriting Obsidian/Canvas/Wiki notes#1457TPAteeq wants to merge 4 commits into
TPAteeq wants to merge 4 commits into
Conversation
…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>
Collaborator
|
Landed on |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Referencesand a markdown headingreferences) are distinct keys butresolve to the same path on disk, so the second
write_text()silentlyoverwrites 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-notededup now keys a
usedset onbase.lower()while still emitting theoriginal-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_slughad the samecase-sensitive
slug in used_slugsmembership (the wiki.py: _safe_filename(label) collisions cause silent article overwrites #497 dedup didn't foldcase); it now folds case the same way.
to_obsidian's_COMMUNITY_*.mdoverview 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.base_1can't silently overwrite a node whose literal label is already
base_1(this collides on case-sensitive filesystems too).
Testing
tests/test_export.pyandtests/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.
test_export.py/test_wiki.pyplus the adjacentobsidian/canvas/CLI/pipeline suites pass (99 tests).
References/referencesand
Parser/parser/PARSERproduces five distinct notes(
References,references_1,Parser,parser_1,PARSER_2) and acanvas whose cards each resolve to a real file.
Out of scope (follow-ups)
report.py'sGRAPH_REPORT.mdemits its own[[_COMMUNITY_...]]hublinks 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.pywrites{stem}.txttranscripts with no dedup, so filesfrom different dirs with case-only-distinct stems can overwrite. Separate,
pre-existing, not label-keyed — maybe worth its own issue.