diff --git a/graphify/export.py b/graphify/export.py index e1f2caa99..9aa110030 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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 + # 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 @@ -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: @@ -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: @@ -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("") @@ -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 @@ -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", diff --git a/tests/test_export.py b/tests/test_export.py index 28a4707a7..57d66797f 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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():