Skip to content
Closed
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
63 changes: 33 additions & 30 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,34 @@ def _cap_filename(s: str, limit: int = 200) -> str:
return f"{truncated}_{digest}"


def _obsidian_safe_stem(label: str) -> str:
"""Filename stem for an Obsidian note / canvas card from a node label.

Strips filesystem-unsafe characters, a trailing ``.md``-family extension
(so ``CLAUDE.md`` does not become ``CLAUDE.md.md``), and a leading ``.`` —
Obsidian hides every note whose name starts with a dot, so ``.env.md``
would be written but invisible in the UI (#2205). The ``dot-`` prefix keeps
the name recognizable; H1 / frontmatter still carry the true label.
"""
cleaned = re.sub(
r'[\\/*?:"<>|#^[\]]',
"",
label.replace("\r\n", " ").replace("\r", " ").replace("\n", " "),
).strip()
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
# Obsidian treats a leading-dot filename as a hidden file (#2205).
if cleaned.startswith("."):
cleaned = "dot-" + cleaned.lstrip(".")
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
Comment on lines +446 to +450

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good call — pushed a regression test for .env / .gitignore stems on both the vault notes and the canvas file nodes.

# strip above but is empty once a downstream tool re-slugs on word chars
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
# `qmd update`). Require at least one word char; else fall back so we never
# emit a "@.md"-style filename. (#1409)
if not re.search(r"\w", cleaned, flags=re.UNICODE):
return "unnamed"
return _cap_filename(cleaned)


def _dedup_node_filenames(G: nx.Graph, safe_name) -> 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
Expand Down Expand Up @@ -497,20 +525,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.
def safe_name(label: str) -> str:
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
# Strip trailing .md/.mdx/.markdown so "CLAUDE.md" doesn't become "CLAUDE.md.md"
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
# strip above but is empty once a downstream tool re-slugs on word chars
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
# `qmd update`). Require at least one word char; else fall back so we never
# emit a "@.md"-style filename. (#1409)
if not re.search(r"\w", cleaned, flags=re.UNICODE):
return "unnamed"
return _cap_filename(cleaned)

node_filename = _dedup_node_filenames(G, safe_name)
node_filename = _dedup_node_filenames(G, _obsidian_safe_stem)

# Helper: compute dominant confidence for a node across all its edges
def _dominant_confidence(node_id: str) -> str:
Expand Down Expand Up @@ -625,7 +640,7 @@ def _community_name(cid) -> str:
community_filename: dict = {}
used_community: set[str] = set()
for cid in communities:
base = f"_COMMUNITY_{safe_name(_community_name(cid))}"
base = f"_COMMUNITY_{_obsidian_safe_stem(_community_name(cid))}"
candidate = base
n = 1
while candidate.lower() in used_community:
Expand Down Expand Up @@ -700,7 +715,7 @@ def _community_name(cid) -> str:
if cross:
lines.append("## Connections to other communities")
for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]):
other_fname = community_filename.get(other_cid) or f"_COMMUNITY_{safe_name(_community_name(other_cid))}"
other_fname = community_filename.get(other_cid) or f"_COMMUNITY_{_obsidian_safe_stem(_community_name(other_cid))}"
lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[{other_fname}]]")
lines.append("")

Expand Down Expand Up @@ -798,21 +813,9 @@ def to_canvas(
# Obsidian canvas color codes (cycle through for communities)
CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # red, orange, yellow, green, cyan, purple

def safe_name(label: str) -> str:
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
# strip above but is empty once a downstream tool re-slugs on word chars
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
# `qmd update`). Require at least one word char; else fall back so we never
# emit a "@.md"-style filename. (#1409)
if not re.search(r"\w", cleaned, flags=re.UNICODE):
return "unnamed"
return _cap_filename(cleaned)

# Build node_filenames if not provided (same dedup logic as to_obsidian)
if node_filenames is None:
node_filenames = _dedup_node_filenames(G, safe_name)
node_filenames = _dedup_node_filenames(G, _obsidian_safe_stem)

# Fallback: with no community data (e.g. --no-cluster builds or a missing
# analysis sidecar) the grid below produces nothing and the canvas is written
Expand Down Expand Up @@ -926,7 +929,7 @@ def safe_name(label: str) -> str:
row = m_idx // inner_cols
nx_x = gx + 20 + col * (180 + 20)
nx_y = gy + 80 + row * (60 + 20)
fname = node_filenames.get(node_id, safe_name(G.nodes[node_id].get("label", node_id)))
fname = node_filenames.get(node_id, _obsidian_safe_stem(G.nodes[node_id].get("label", node_id)))
canvas_nodes.append({
"id": f"n_{node_id}",
"type": "file",
Expand Down
30 changes: 30 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,36 @@ def test_to_canvas_never_emits_punctuation_only_filenames():
assert not bad, f"punctuation-only canvas filenames: {bad}"


def test_to_obsidian_leading_dot_labels_are_not_hidden_filenames():
"""#2205: Obsidian hides notes whose names start with `.` — `.env` must
become `dot-env.md` (and canvas must point at the same stem)."""
import networkx as nx
G = nx.Graph()
G.add_node("n_env", label=".env", source_file=".env", type="document")
G.add_node("n_gi", label=".gitignore", source_file=".gitignore", type="document")
G.add_node("n_readme", label="README", source_file="README.md", type="document")
G.add_edge("n_readme", "n_env", relation="references")
communities = {0: ["n_env", "n_gi", "n_readme"]}
with tempfile.TemporaryDirectory() as tmp:
to_obsidian(G, communities, tmp)
stems = {p.stem for p in Path(tmp).rglob("*.md") if not p.name.startswith("_")}
assert "dot-env" in stems, stems
assert "dot-gitignore" in stems, stems
assert not any(s.startswith(".") for s in stems), stems

canvas = Path(tmp) / "graph.canvas"
to_canvas(G, communities, str(canvas))
data = json.loads(canvas.read_text(encoding="utf-8"))
file_stems = {
Path(n["file"]).stem
for n in data["nodes"]
if n.get("type") == "file"
}
assert "dot-env" in file_stems, file_stems
assert "dot-gitignore" in file_stems, file_stems
assert not any(s.startswith(".") for s in file_stems), file_stems


# ── Existing-vault safety: graphify must not clobber user notes / .obsidian (#1506) ──

def _two_node_graph():
Expand Down
Loading