Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 90 additions & 9 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import shutil
import sys
import unicodedata
from collections import Counter
from datetime import date
from pathlib import Path
Expand Down Expand Up @@ -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
Expand All @@ -470,18 +482,62 @@ 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
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


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 _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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionto_obsidian()

fans out to 8 callees (efferent coupling); 31 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

G: nx.Graph,
communities: dict[int, list[str]],
Expand Down Expand Up @@ -512,11 +568,32 @@ 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)
_unicode_normalizing = _detect_unicode_normalizing_fs(out)

def _own_key(rel_name: str) -> str:
return _filesystem_name_key(
rel_name,
case_insensitive=_case_insensitive,
unicode_normalizing=_unicode_normalizing,
)

_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)
Expand All @@ -528,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:
Expand Down Expand Up @@ -646,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
Expand Down Expand Up @@ -763,7 +840,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()
Expand Down
Loading