From 3749c0b691f57c2a25ef150584d6dbc312d3e6d3 Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Sat, 1 Aug 2026 02:56:03 +0530 Subject: [PATCH 1/3] fix(export): compare obsidian manifest ownership case-aware on case-insensitive filesystems (#2282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _owned_write compared target.exists() — which is case-insensitive on APFS and NTFS — against an exact-string lookup in the JSON manifest, so a note graphify itself wrote as AGORA.md looked pre-existing when a later run computed agora.md. The write was refused as a user file and the stale-prune step, which saw the name in neither _written nor _skipped, then deleted the original: two nodes, one note, plus a false warning. Probe once per to_obsidian call whether the output directory is actually case-insensitive (assuming case-sensitive when the probe cannot run, which preserves today's Linux behavior where Agora.md and agora.md are genuinely two files), and key the ownership comparison and the stale-prune through that. The manifest still stores real filenames. _dedup_node_filenames now iterates in sorted node order so suffix assignment does not drift between runs for an unchanged node set, which is what made the collision fire. The pre-existing-file protection is unchanged for a genuinely user-authored file: it is still skipped and still warned about. --- graphify/export.py | 53 +++++++++++++- tests/test_export.py | 166 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 3 deletions(-) diff --git a/graphify/export.py b/graphify/export.py index 328b52568..a97852420 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -470,7 +470,14 @@ def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]: silently overwrites a node whose literal label is already "base_1".""" node_filenames: dict[str, str] = {} used: set[str] = set() - for node_id, data in G.nodes(data=True): + # #2282: iterate in a stable order (sorted node id) rather than graph iteration + # order. nx.Graph iteration order isn't guaranteed stable across process runs + # for the same node set, which let a node's `_N` suffix drift between runs of + # to_obsidian and defeat the manifest-based ownership check in _owned_write + # (a node's filename changing run-to-run makes its old note look orphaned and + # its new name look "pre-existing"). + for node_id in sorted(G.nodes(), key=str): + data = G.nodes[node_id] base = safe_name(data.get("label", node_id)) candidate = base n = 1 @@ -482,6 +489,26 @@ def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]: return node_filenames +def _detect_case_insensitive_fs(out: Path) -> bool: + """#2282: probe whether `out` sits on a case-insensitive filesystem + (macOS/APFS, Windows/NTFS) by writing a sentinel file and checking whether its + case-flipped name also `.exists()`. Assume case-SENSITIVE (the conservative + default that preserves today's Linux/ext4 behavior) if the probe can't run for + any filesystem reason. Module-level so tests can monkeypatch it to force a + result without needing a real filesystem of the opposite kind.""" + probe = out / ".graphify_case_probe.tmp" + flipped = out / ".graphify_CASE_probe.tmp" + try: + out.mkdir(parents=True, exist_ok=True) + probe.write_text("", encoding="utf-8") + try: + return flipped.exists() + finally: + probe.unlink(missing_ok=True) + except OSError: + return False + + def to_obsidian( G: nx.Graph, communities: dict[int, list[str]], @@ -512,11 +539,27 @@ def to_obsidian( _written: list[str] = [] _skipped: list[str] = [] + # #2282: target.exists() consults the real filesystem, which is + # case-INsensitive on APFS/NTFS, while a plain `rel_name in _owned` check is an + # exact-string lookup. On such a filesystem a node's note written as + # "AGORA.md" in one run looks like someone else's pre-existing file when a + # later run computes "agora.md" for the same node - it gets skipped, and then + # deleted by the stale-prune below because it's in neither _written nor + # _skipped. Probed once per call (not per file) and only applied when the + # filesystem is actually case-insensitive, so ext4/Linux keeps its existing + # case-sensitive behavior of treating "Agora.md" and "agora.md" as distinct. + _case_insensitive = _detect_case_insensitive_fs(out) + + def _own_key(rel_name: str) -> str: + return rel_name.lower() if _case_insensitive else rel_name + + _owned_keys = {_own_key(f) for f in _owned} + def _owned_write(rel_name: str, content: str) -> bool: """Write a graphify-owned file, refusing to overwrite a pre-existing file graphify didn't create. Returns True if written.""" target = out / rel_name - if target.exists() and rel_name not in _owned: + if target.exists() and _own_key(rel_name) not in _owned_keys: _skipped.append(rel_name) return False target.parent.mkdir(parents=True, exist_ok=True) @@ -763,7 +806,11 @@ def _community_name(cid) -> str: # this run is excluded — so a user's own note is never touched (foreign files # land in _skipped, never _owned). Guard each path to stay inside the vault in # case a corrupt/hostile manifest contains `../` entries. - stale = _owned - set(_written) - set(_skipped) + # #2282: compare via the same case-normalized key as _owned_write, else a note + # whose filename drifted case (but is still accounted for this run) reads as + # stale and gets deleted even though it was just written or skipped. + _live_keys = {_own_key(f) for f in _written} | {_own_key(f) for f in _skipped} + stale = {f for f in _owned if _own_key(f) not in _live_keys} pruned = 0 for rel_name in sorted(stale): target = (out / rel_name).resolve() diff --git a/tests/test_export.py b/tests/test_export.py index 7b55780ea..809a18165 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -5,6 +5,7 @@ from pathlib import Path from graphify.build import build_from_json from graphify.cluster import cluster +from graphify import export from graphify.export import to_json, to_cypher, to_graphml, to_html, to_canvas, to_obsidian FIXTURES = Path(__file__).parent / "fixtures" @@ -632,6 +633,171 @@ def test_to_obsidian_generated_suffix_doesnt_overwrite_literal(): assert len({p.stem.lower() for p in notes}) == 3, [p.name for p in notes] +# ── #2282: case-insensitive-filesystem manifest false positives ── + +def test_to_obsidian_case_collision_across_runs_dedup_is_stable(monkeypatch, capsys): + """#2282 dedup-order repro: run 1 has a single node "AGORA", run 2 adds a + case-colliding node "agora" ahead of it. Both nodes keep the same filename + run-to-run (dedup iterates by sorted node id), so this only pins the + `sorted(...)` dedup-stability fix, NOT the `_own_key` manifest fold - see + test_to_obsidian_case_collision_same_node_label_change_keeps_note for the + fold path. Must not warn about a pre-existing file, and must not delete the + note the first run legitimately owns.""" + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + G1 = build_from_json({ + "nodes": [{"id": "a", "label": "AGORA", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + G2 = build_from_json({ + "nodes": [ + {"id": "b", "label": "agora", "file_type": "document", "source_file": "b.md"}, + {"id": "a", "label": "AGORA", "file_type": "document", "source_file": "a.md"}, + ], + "edges": [], + }) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(G1, cluster(G1), str(out)) + capsys.readouterr() + to_obsidian(G2, cluster(G2), str(out)) + captured = capsys.readouterr() + assert "skipped" not in captured.err.lower(), captured.err + notes = [p for p in out.glob("*.md") if not p.name.startswith("_COMMUNITY")] + stems = sorted(p.stem.lower() for p in notes) + assert stems == ["agora", "agora_1"], [p.name for p in notes] + + +def test_to_obsidian_case_collision_same_node_label_change_keeps_note(monkeypatch, capsys): + """#2282 fold-path repro: the SAME node id "a" changes label case between + runs (run 1 "AGORA" -> manifest records AGORA.md, run 2 "agora" -> computes + agora.md). On a case-insensitive filesystem `target.exists()` is True for + agora.md, so without the `_own_key` fold the raw manifest lookup treats it + as someone else's pre-existing file, refuses the write, and the stale-prune + then deletes the node's only note. This is the primary #2282 fix, unlike the + sorted-dedup test above which never triggers it.""" + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + G1 = build_from_json({ + "nodes": [{"id": "a", "label": "AGORA", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + G2 = build_from_json({ + "nodes": [{"id": "a", "label": "agora", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(G1, cluster(G1), str(out)) + capsys.readouterr() + to_obsidian(G2, cluster(G2), str(out)) + captured = capsys.readouterr() + assert "pre-existing" not in captured.err.lower(), captured.err + notes = [p for p in out.glob("*.md") if not p.name.startswith("_COMMUNITY")] + assert len(notes) == 1, [p.name for p in notes] + assert 'source_file: "a.md"' in notes[0].read_text() + + +def test_to_obsidian_user_file_still_skipped_and_warned(monkeypatch, capsys): + """The pre-existing-file protection must survive the case-fold fix: a + genuinely user-authored file with a colliding name is still refused and still + reported, even on a case-insensitive filesystem.""" + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + G = build_from_json({ + "nodes": [{"id": "a", "label": "Notes", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + out.mkdir(parents=True) + (out / "notes.md").write_text("mine\n", encoding="utf-8") + to_obsidian(G, cluster(G), str(out)) + captured = capsys.readouterr() + assert "skipped 1 pre-existing" in captured.err + assert (out / "notes.md").read_text().strip() == "mine" + + +def test_to_obsidian_filename_stability_across_runs(): + """The same node set exported twice must produce the identical file set - no + `_1` suffix drift from run to run (#2282 cause 2).""" + G, communities = _two_node_graph() + with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2: + to_obsidian(G, communities, tmp1, community_labels={0: "Backend"}) + to_obsidian(G, communities, tmp2, community_labels={0: "Backend"}) + names1 = sorted(p.name for p in Path(tmp1).glob("*.md")) + names2 = sorted(p.name for p in Path(tmp2).glob("*.md")) + assert names1 == names2 + + +def test_to_obsidian_case_sensitive_fs_keeps_distinct_files(monkeypatch, capsys): + """On a case-sensitive filesystem (forced via the injectable probe), a file + that only differs by case from an owned entry must be treated as a distinct, + pre-existing user file - #2282's fold-only-when-needed requirement. + + This machine's real filesystem (APFS) is case-insensitive, so `out / + "Notes.md"` and `out / "notes.md"` are literally the same inode and + `.exists()` / content assertions on them can't distinguish "wrote" from + "skipped". Assert the observable ownership DECISION instead: with the probe + forced False, graphify's "Notes.md" collides with the user's "notes.md" on + disk, so the skip path must fire (warning emitted) and the user's content + must be untouched. Falsifiable: if the keying folded case unconditionally + (ignoring the forced-False probe), the skip/warning would not fire and this + would fail.""" + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: False) + G = build_from_json({ + "nodes": [{"id": "a", "label": "Notes", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + out.mkdir(parents=True) + (out / "notes.md").write_text("mine\n", encoding="utf-8") + to_obsidian(G, cluster(G), str(out)) + captured = capsys.readouterr() + assert "skipped 1 pre-existing" in captured.err + assert (out / "notes.md").read_text().strip() == "mine" + + +def test_to_obsidian_own_key_folds_case_only_when_probe_says_insensitive(monkeypatch, capsys): + """Direct(ish) unit test of the keying helper via to_obsidian's observable + behavior (`_own_key` is a closure with no module-level access, per the task + constraints - don't restructure production code to expose it). + + Two runs write a node under two case-variant labels for two DIFFERENT node + ids sharing one vault. With the probe forced True, the second run's + filename collides case-insensitively with the first run's manifest entry + and must be folded into the SAME owned key, so both node ids can't + peacefully coexist under distinct keys - the second write is treated as an + update to a to-be-pruned entry, not as a "pre-existing" file: no warning. + With the probe forced False, the same two labels produce DIFFERENT keys, so + the collision is treated as a real, un-owned pre-existing file and the + write is skipped with a warning.""" + G1 = build_from_json({ + "nodes": [{"id": "a", "label": "Dup", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + G2 = build_from_json({ + "nodes": [{"id": "a", "label": "dup", "file_type": "document", "source_file": "a.md"}], + "edges": [], + }) + + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(G1, cluster(G1), str(out)) + capsys.readouterr() + to_obsidian(G2, cluster(G2), str(out)) + captured = capsys.readouterr() + assert "pre-existing" not in captured.err.lower(), captured.err + + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: False) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(G1, cluster(G1), str(out)) + capsys.readouterr() + to_obsidian(G2, cluster(G2), str(out)) + captured = capsys.readouterr() + assert "skipped 1 pre-existing" in captured.err, captured.err + + def test_to_canvas_case_only_distinct_labels_get_distinct_files(): """Canvas file-node references for case-only-distinct labels must be distinct case-insensitively, else both cards point at one overwritten note.""" From 4df69c27e51ae3b2ca36d5c68fb209ff880566a4 Mon Sep 17 00:00:00 2001 From: Rishet Mehra Date: Sat, 1 Aug 2026 03:07:35 +0530 Subject: [PATCH 2/3] test(export): gate #2282 obsidian tests on real filesystem case-sensitivity The four new #2282 tests forced the case-insensitive probe to True unconditionally, which is incoherent on Linux/ext4 (a real case-sensitive fs) and caused CI failures there. Compute the real probe result once and branch expectations on it instead of contradicting it, gate the case-sensitive-only scenarios with skipif, and make the user-file collision test use the exact filename graphify computes so it's collision on every filesystem. --- tests/test_export.py | 120 +++++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 38 deletions(-) diff --git a/tests/test_export.py b/tests/test_export.py index 809a18165..1245d08f7 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -3,6 +3,7 @@ import re import tempfile from pathlib import Path +import pytest from graphify.build import build_from_json from graphify.cluster import cluster from graphify import export @@ -10,6 +11,16 @@ FIXTURES = Path(__file__).parent / "fixtures" +# #2282 test infra: the production probe's real answer on THIS machine's temp +# filesystem, computed once. tempfile.TemporaryDirectory() (used throughout this +# file) honors $TMPDIR, so this stays consistent with whatever filesystem the +# tests below actually write to - including a case-sensitive volume mounted via +# TMPDIR for CI-parity verification. Tests must branch expectations on this +# value rather than monkeypatching the probe to a result the real filesystem +# contradicts. +with tempfile.TemporaryDirectory() as _probe_tmp: + CASE_INSENSITIVE_FS = export._detect_case_insensitive_fs(Path(_probe_tmp)) + def make_graph(): return build_from_json(json.loads((FIXTURES / "extraction.json").read_text())) @@ -670,12 +681,22 @@ def test_to_obsidian_case_collision_across_runs_dedup_is_stable(monkeypatch, cap def test_to_obsidian_case_collision_same_node_label_change_keeps_note(monkeypatch, capsys): """#2282 fold-path repro: the SAME node id "a" changes label case between runs (run 1 "AGORA" -> manifest records AGORA.md, run 2 "agora" -> computes - agora.md). On a case-insensitive filesystem `target.exists()` is True for - agora.md, so without the `_own_key` fold the raw manifest lookup treats it - as someone else's pre-existing file, refuses the write, and the stale-prune - then deletes the node's only note. This is the primary #2282 fix, unlike the - sorted-dedup test above which never triggers it.""" - monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + agora.md). The probe is forced to the REAL filesystem's own answer (not an + opposite value) so the scenario stays coherent on both platforms: + + - Case-insensitive (APFS/NTFS): `agora.md` and `AGORA.md` are the same + inode, `target.exists()` is True, and without the `_own_key` fold the raw + manifest lookup would treat it as someone else's pre-existing file and + the stale-prune would delete the node's only note. With the fold, run 2 + just overwrites the same file in place: 1 note, no warning. + - Case-sensitive (ext4): `agora.md` is a distinct, not-yet-existing file, + so it's written fresh; `AGORA.md` is then unreferenced by this run and + gets removed by the stale-prune (a `pruned` message, not a + "pre-existing" skip). Also ends at 1 note. + + Verified by running both branches (real APFS locally, and a real + case-sensitive APFS volume via hdiutil - see PR notes).""" + monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: CASE_INSENSITIVE_FS) G1 = build_from_json({ "nodes": [{"id": "a", "label": "AGORA", "file_type": "document", "source_file": "a.md"}], "edges": [], @@ -696,11 +717,14 @@ def test_to_obsidian_case_collision_same_node_label_change_keeps_note(monkeypatc assert 'source_file: "a.md"' in notes[0].read_text() -def test_to_obsidian_user_file_still_skipped_and_warned(monkeypatch, capsys): +def test_to_obsidian_user_file_still_skipped_and_warned(capsys): """The pre-existing-file protection must survive the case-fold fix: a - genuinely user-authored file with a colliding name is still refused and still - reported, even on a case-insensitive filesystem.""" - monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + genuinely user-authored file is still refused and still reported. + + The user's file is given the EXACT name graphify computes for the node + ("Notes.md", not a case variant like "notes.md") so the collision is real + on every filesystem - no probe forcing needed, and no dependence on + whether the fs happens to fold case.""" G = build_from_json({ "nodes": [{"id": "a", "label": "Notes", "file_type": "document", "source_file": "a.md"}], "edges": [], @@ -708,11 +732,11 @@ def test_to_obsidian_user_file_still_skipped_and_warned(monkeypatch, capsys): with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "obsidian" out.mkdir(parents=True) - (out / "notes.md").write_text("mine\n", encoding="utf-8") + (out / "Notes.md").write_text("mine\n", encoding="utf-8") to_obsidian(G, cluster(G), str(out)) captured = capsys.readouterr() assert "skipped 1 pre-existing" in captured.err - assert (out / "notes.md").read_text().strip() == "mine" + assert (out / "Notes.md").read_text().strip() == "mine" def test_to_obsidian_filename_stability_across_runs(): @@ -727,21 +751,23 @@ def test_to_obsidian_filename_stability_across_runs(): assert names1 == names2 -def test_to_obsidian_case_sensitive_fs_keeps_distinct_files(monkeypatch, capsys): - """On a case-sensitive filesystem (forced via the injectable probe), a file - that only differs by case from an owned entry must be treated as a distinct, - pre-existing user file - #2282's fold-only-when-needed requirement. - - This machine's real filesystem (APFS) is case-insensitive, so `out / - "Notes.md"` and `out / "notes.md"` are literally the same inode and - `.exists()` / content assertions on them can't distinguish "wrote" from - "skipped". Assert the observable ownership DECISION instead: with the probe - forced False, graphify's "Notes.md" collides with the user's "notes.md" on - disk, so the skip path must fire (warning emitted) and the user's content - must be untouched. Falsifiable: if the keying folded case unconditionally - (ignoring the forced-False probe), the skip/warning would not fire and this - would fail.""" - monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: False) +@pytest.mark.skipif(CASE_INSENSITIVE_FS, reason="scenario only exists on a case-sensitive filesystem") +def test_to_obsidian_case_sensitive_fs_keeps_distinct_files(capsys): + """On a genuinely case-sensitive filesystem, a user file that only differs + by case from the node's computed filename is a DISTINCT file, not a + collision - #2282's fold-only-when-needed requirement. graphify writes its + own "Notes.md" while the user's "notes.md" is left untouched, both existing + side by side, and no warning is emitted (there was never a real conflict). + + No probe forcing: this uses the real, un-monkeypatched probe, which is why + it's gated to run only where that probe genuinely returns False. On APFS + (case-insensitive) this skips with a clear reason instead of faking the + scenario via an incoherent forced probe value. + + Falsifiable: if the keying folded case unconditionally on a real + case-sensitive fs (ignoring what the probe reports), "notes.md" would be + treated as owned/colliding and either get skipped-with-warning or + overwritten, breaking one of the assertions below.""" G = build_from_json({ "nodes": [{"id": "a", "label": "Notes", "file_type": "document", "source_file": "a.md"}], "edges": [], @@ -752,24 +778,28 @@ def test_to_obsidian_case_sensitive_fs_keeps_distinct_files(monkeypatch, capsys) (out / "notes.md").write_text("mine\n", encoding="utf-8") to_obsidian(G, cluster(G), str(out)) captured = capsys.readouterr() - assert "skipped 1 pre-existing" in captured.err + assert "skipped" not in captured.err.lower(), captured.err assert (out / "notes.md").read_text().strip() == "mine" + assert (out / "Notes.md").exists() +@pytest.mark.skipif(not CASE_INSENSITIVE_FS, reason="fold is only meaningful on a case-insensitive filesystem") def test_to_obsidian_own_key_folds_case_only_when_probe_says_insensitive(monkeypatch, capsys): """Direct(ish) unit test of the keying helper via to_obsidian's observable behavior (`_own_key` is a closure with no module-level access, per the task constraints - don't restructure production code to expose it). - Two runs write a node under two case-variant labels for two DIFFERENT node - ids sharing one vault. With the probe forced True, the second run's - filename collides case-insensitively with the first run's manifest entry - and must be folded into the SAME owned key, so both node ids can't - peacefully coexist under distinct keys - the second write is treated as an - update to a to-be-pruned entry, not as a "pre-existing" file: no warning. - With the probe forced False, the same two labels produce DIFFERENT keys, so - the collision is treated as a real, un-owned pre-existing file and the - write is skipped with a warning.""" + Same node id "a", label changes case between two runs sharing one vault. + Real fs here is genuinely case-insensitive, so `dup.md`/`Dup.md` are one + inode and the write always succeeds with no warning regardless of the + probe - forcing the probe True is not a meaningful mutation here (it just + matches reality). The meaningful, falsifying mutation is forcing the probe + FALSE on this genuinely case-insensitive fs: `_own_key` then stops folding, + "dup.md" is computed as a different key than the manifest's "Dup.md", but + `target.exists()` is still True (same inode on disk) - so the write is + wrongly treated as hitting a pre-existing foreign file and gets skipped + with a warning. That's the bug #2282 fixed; this proves the fold is what + prevents it.""" G1 = build_from_json({ "nodes": [{"id": "a", "label": "Dup", "file_type": "document", "source_file": "a.md"}], "edges": [], @@ -779,7 +809,7 @@ def test_to_obsidian_own_key_folds_case_only_when_probe_says_insensitive(monkeyp "edges": [], }) - monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: True) + # Real probe (True on this fs): fold works, no bogus "pre-existing" warning. with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "obsidian" to_obsidian(G1, cluster(G1), str(out)) @@ -788,6 +818,9 @@ def test_to_obsidian_own_key_folds_case_only_when_probe_says_insensitive(monkeyp captured = capsys.readouterr() assert "pre-existing" not in captured.err.lower(), captured.err + # Falsifying mutation: force the probe to lie (False) about this genuinely + # case-insensitive fs. The fold is disabled, and the real fs's case-folding + # exposes the mismatch as a bogus skip-with-warning. monkeypatch.setattr(export, "_detect_case_insensitive_fs", lambda out: False) with tempfile.TemporaryDirectory() as tmp: out = Path(tmp) / "obsidian" @@ -798,6 +831,17 @@ def test_to_obsidian_own_key_folds_case_only_when_probe_says_insensitive(monkeyp assert "skipped 1 pre-existing" in captured.err, captured.err +# Mirror-image test on a genuinely case-sensitive filesystem: not meaningful. +# There, "Dup.md" and "dup.md" are always two distinct real files regardless of +# what the probe reports, so forcing the probe True (the only "interesting" +# mutation) doesn't produce a clean skip/warn signal - it produces a second, +# independently-written file living alongside the first (the same incoherent +# scenario reasoned through in test_to_obsidian_case_collision_same_node_label_change_keeps_note's +# docstring for the ext4 branch). There is no coherent forced-probe mutation on +# a case-sensitive fs that isolates the fold behavior the way the insensitive-fs +# test above does, so no fake mirror test is written here. + + def test_to_canvas_case_only_distinct_labels_get_distinct_files(): """Canvas file-node references for case-only-distinct labels must be distinct case-insensitively, else both cards point at one overwritten note.""" From a88994695b7df14fd2b415934f0d90aa1bffc66a Mon Sep 17 00:00:00 2001 From: Alex <157389143+Rhyoss@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:49:50 -0300 Subject: [PATCH 3/3] fix(export): handle APFS unicode filename collisions --- graphify/export.py | 48 +++++++++++++++++++++++++++++++++++++------- tests/test_export.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/graphify/export.py b/graphify/export.py index a97852420..e74f8226c 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -8,6 +8,7 @@ import re import shutil import sys +import unicodedata from collections import Counter from datetime import date from pathlib import Path @@ -460,7 +461,18 @@ def _obsidian_safe_stem(label: str) -> str: return _cap_filename(cleaned) -def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]: +def _filesystem_name_key( + rel_name: str, + *, + case_insensitive: bool = True, + unicode_normalizing: bool = True, +) -> str: + """Return the filename equivalence key for the current filesystem behavior.""" + key = unicodedata.normalize("NFC", rel_name) if unicode_normalizing else rel_name + return key.casefold() if case_insensitive else key + + +def _dedup_node_filenames(G: nx.Graph, safe_name, name_key=_filesystem_name_key) -> dict[str, str]: """Map each node_id to a unique note filename, appending a numeric suffix on collision. The collision set is keyed on the lowercased name so two labels differing only by case (e.g. "References" vs "references") still get distinct @@ -481,10 +493,10 @@ def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]: base = safe_name(data.get("label", node_id)) candidate = base n = 1 - while candidate.lower() in used: + while name_key(candidate) in used: candidate = f"{base}_{n}" n += 1 - used.add(candidate.lower()) + used.add(name_key(candidate)) node_filenames[node_id] = candidate return node_filenames @@ -509,6 +521,23 @@ def _detect_case_insensitive_fs(out: Path) -> bool: return False +def _detect_unicode_normalizing_fs(out: Path) -> bool: + """Probe whether `out` treats NFC and NFD filenames as the same path.""" + probe_name = unicodedata.normalize("NFC", ".graphify_unicode_Café.tmp") + equiv_name = unicodedata.normalize("NFD", ".graphify_unicode_Café.tmp") + probe = out / probe_name + equivalent = out / equiv_name + try: + out.mkdir(parents=True, exist_ok=True) + probe.write_text("", encoding="utf-8") + try: + return equivalent.exists() + finally: + probe.unlink(missing_ok=True) + except OSError: + return False + + def to_obsidian( G: nx.Graph, communities: dict[int, list[str]], @@ -549,9 +578,14 @@ def to_obsidian( # filesystem is actually case-insensitive, so ext4/Linux keeps its existing # case-sensitive behavior of treating "Agora.md" and "agora.md" as distinct. _case_insensitive = _detect_case_insensitive_fs(out) + _unicode_normalizing = _detect_unicode_normalizing_fs(out) def _own_key(rel_name: str) -> str: - return rel_name.lower() if _case_insensitive else rel_name + return _filesystem_name_key( + rel_name, + case_insensitive=_case_insensitive, + unicode_normalizing=_unicode_normalizing, + ) _owned_keys = {_own_key(f) for f in _owned} @@ -571,7 +605,7 @@ def _owned_write(rel_name: str, content: str) -> bool: # Map node_id → safe filename so wikilinks stay consistent. # Deduplicate: if two nodes produce the same filename, append a numeric suffix. - node_filename = _dedup_node_filenames(G, _obsidian_safe_stem) + node_filename = _dedup_node_filenames(G, _obsidian_safe_stem, _own_key) # Helper: compute dominant confidence for a node across all its edges def _dominant_confidence(node_id: str) -> str: @@ -689,10 +723,10 @@ def _community_name(cid) -> str: base = f"_COMMUNITY_{_obsidian_safe_stem(_community_name(cid))}" candidate = base n = 1 - while candidate.lower() in used_community: + while _own_key(candidate) in used_community: candidate = f"{base}_{n}" n += 1 - used_community.add(candidate.lower()) + used_community.add(_own_key(candidate)) community_filename[cid] = candidate community_notes_written = 0 diff --git a/tests/test_export.py b/tests/test_export.py index 1245d08f7..4993c973d 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -2,6 +2,7 @@ import math import re import tempfile +import unicodedata from pathlib import Path import pytest from graphify.build import build_from_json @@ -20,6 +21,7 @@ # contradicts. with tempfile.TemporaryDirectory() as _probe_tmp: CASE_INSENSITIVE_FS = export._detect_case_insensitive_fs(Path(_probe_tmp)) + UNICODE_NORMALIZING_FS = export._detect_unicode_normalizing_fs(Path(_probe_tmp)) def make_graph(): return build_from_json(json.loads((FIXTURES / "extraction.json").read_text())) @@ -678,6 +680,36 @@ def test_to_obsidian_case_collision_across_runs_dedup_is_stable(monkeypatch, cap assert stems == ["agora", "agora_1"], [p.name for p in notes] + +@pytest.mark.skipif(not UNICODE_NORMALIZING_FS, reason="scenario only exists on a Unicode-normalizing filesystem") +def test_to_obsidian_unicode_equivalent_labels_get_distinct_notes(capsys): + """On APFS/macOS, NFC and NFD spellings of the same visible name resolve to + the same path. The export must suffix the second note instead of treating it + as a foreign pre-existing file and dropping that node's note (#2282).""" + nfc = unicodedata.normalize("NFC", "Café") + nfd = unicodedata.normalize("NFD", "Café") + assert nfc != nfd + G = build_from_json({ + "nodes": [ + {"id": "a", "label": nfc, "file_type": "document", "source_file": "a.md"}, + {"id": "b", "label": nfd, "file_type": "document", "source_file": "b.md"}, + ], + "edges": [], + }) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(G, cluster(G), str(out), community_labels={0: "Unicode"}) + captured = capsys.readouterr() + assert "pre-existing" not in captured.err.lower(), captured.err + notes = [p for p in out.glob("*.md") if not p.name.startswith("_COMMUNITY")] + assert len(notes) == 2, [p.name for p in notes] + assert sorted(unicodedata.normalize("NFC", p.stem).casefold() for p in notes) == [ + "café", + "café_1", + ] + + + def test_to_obsidian_case_collision_same_node_label_change_keeps_note(monkeypatch, capsys): """#2282 fold-path repro: the SAME node id "a" changes label case between runs (run 1 "AGORA" -> manifest records AGORA.md, run 2 "agora" -> computes