diff --git a/graphify/wiki.py b/graphify/wiki.py index b08b22eb6..88c001e1f 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -30,6 +30,12 @@ _SLUG_SUFFIX_RESERVE = 5 _UNSAFE_SLUG_CHARS = re.compile(r'[<>:"/\\|?*#%\x00-\x1f\x7f]') +# The navigation footer links the catalog by its fixed filename rather than via +# the label resolver, so an article whose own label is "index" resolves to its +# own (deduplicated) file instead of the catalog. +_INDEX_LINK = "[index](index.md)" + + def _safe_filename(name: str, limit: int = 200) -> str: """Make a label safe for use as a filename across platforms AND as a markdown link destination. @@ -178,7 +184,7 @@ def _community_article( lines.append(f"- {conf}: {n} ({pct}%)") lines.append("") - lines += ["---", "", f"*Part of the graphify knowledge wiki. See {_md_link('index', resolver)} to navigate.*"] + lines += ["---", "", f"*Part of the graphify knowledge wiki. See {_INDEX_LINK} to navigate.*"] return "\n".join(lines) @@ -224,7 +230,7 @@ def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str], node_commun ) lines.append("") - lines += ["---", "", f"*Part of the graphify knowledge wiki. See {_md_link('index', resolver)} to navigate.*"] + lines += ["---", "", f"*Part of the graphify knowledge wiki. See {_INDEX_LINK} to navigate.*"] return "\n".join(lines) @@ -338,7 +344,10 @@ def to_wiki( node_community: dict[str, int] = {n: cid for cid, nodes in communities.items() for n in nodes} count = 0 - used_slugs: set[str] = set() + # "index" is reserved for the catalog written below: an article slugged + # "index" (a community named after an `index()` hub, an `Index` god node on a + # case-insensitive filesystem) would otherwise be overwritten by index.md. + used_slugs: set[str] = {"index"} # Articles are capped against THIS wiki directory, not just NAME_MAX: on # Windows a 200-char slug under an ordinary graphify-out/wiki/ overruns @@ -368,7 +377,7 @@ def to_wiki( # _md_link renders them as plain text. Communities are slugged before god nodes # (and setdefault keeps the first), preserving the filename-assignment order # the case-collision dedup relies on. - resolver: dict[str, str] = {"index": "index"} + resolver: dict[str, str] = {} community_slugs: dict[int, str] = {} for cid in communities: diff --git a/tests/test_wiki.py b/tests/test_wiki.py index 130da843d..2e4d0f1a5 100644 --- a/tests/test_wiki.py +++ b/tests/test_wiki.py @@ -309,6 +309,33 @@ def test_to_wiki_god_node_label_case_collides_with_community(tmp_path): assert len(set(lowered)) == len(lowered), [p.name for p in articles] +def test_to_wiki_article_labelled_index_does_not_overwrite_index_md(tmp_path): + """index.md is the wiki's reserved entry point. A community whose label slugs + to "index" (the default hub labeler names a community after its hub node, so a + hub `index()` view or an `Index` class does this) or a god node labelled + `index()` must get its own file, not be written to index.md and then + overwritten by the catalog - which also made its catalog link point back at + the index itself.""" + G = nx.Graph() + G.add_node("n1", label="index()", file_type="code", source_file="views.py") + G.add_node("n2", label="render", file_type="code", source_file="views.py") + G.add_node("n3", label="Index", file_type="code", source_file="search.py") + G.add_node("n4", label="query", file_type="code", source_file="search.py") + G.add_edge("n1", "n2", relation="calls", confidence="EXTRACTED", weight=1.0) + G.add_edge("n3", "n4", relation="calls", confidence="EXTRACTED", weight=1.0) + communities = {0: ["n1", "n2"], 1: ["n3", "n4"]} + labels = {0: "index", 1: "Search"} + god_nodes = [{"id": "n3", "label": "Index", "degree": 1}] + n = to_wiki(G, communities, tmp_path, community_labels=labels, god_nodes_data=god_nodes) + articles = [p for p in tmp_path.glob("*.md") if p.stem.lower() != "index"] + # every article survives as its own file next to the catalog + assert len(articles) == n == 3, sorted(p.name for p in tmp_path.glob("*.md")) + index_text = (tmp_path / "index.md").read_text(encoding="utf-8") + assert index_text.startswith("# Knowledge Graph Index") + # no catalog entry links back to the catalog itself + assert "(index.md)" not in index_text, index_text + + # Regression tests for portable wiki links - Obsidian [[wikilinks]] break in # every non-Obsidian renderer (VS Code preview, GitHub, GitLab, plain browsers).