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
40 changes: 40 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4968,6 +4968,46 @@ def _portable_out_of_root_sf(p: Path) -> str:
stem_forms[path.resolve()] = (
new_id, [old_pref_abs, old_pref, new_id]
)
# Obsidian-style wikilink resolution (#2211 follow-up). ``[[name]]`` is a
# NAME reference resolved against the whole vault, not a relative path;
# markdown.py can only guess a sibling because a per-file extractor has no
# corpus view. Any guess that missed is stamped with ``wikilink_name``, and
# this is the first point that knows every in-root file, so repoint those
# edges here. Path-qualified links never carry the stamp, so relative-link
# semantics are untouched. On an incremental run the index only covers the
# changed batch plus stamped targets, exactly like the target_file remap
# above -- a full scan resolves the rest.
_wl_edges = [e for e in all_edges if e.get("wikilink_name")]
if _wl_edges and root is not None:
_DOC_EXTS = {".md", ".mdx", ".qmd", ".markdown", ".rst", ".txt"}
name_index: dict[str, list[Path]] = {}
for _p in remap_paths:
if _p.suffix.lower() not in _DOC_EXTS:
continue
try:
_rel = _p.resolve().relative_to(root)
except (OSError, RuntimeError, ValueError):
continue
name_index.setdefault(_rel.stem.lower(), []).append(_rel)
for _e in _wl_edges:
_cands = name_index.get(str(_e.pop("wikilink_name")).lower())
if not _cands:
continue # genuinely broken link: stays dangling, as before
_src_dir = None
_sf = _e.get("source_file")
if _sf:
try:
_src_dir = Path(_sf).resolve().relative_to(root).parent
except (OSError, RuntimeError, ValueError):
_src_dir = None
# Deterministic pick: same folder as the linking note first (mirrors
# Obsidian), then the shallowest path, then lexicographic.
_chosen = min(
_cands,
key=lambda c: (c.parent != _src_dir, len(c.parts), str(c)),
)
_e["target"] = _file_node_id(_chosen)

if id_remap:
for n in all_nodes:
if n.get("id") in id_remap:
Expand Down
23 changes: 19 additions & 4 deletions graphify/extractors/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,15 @@ def add_node(nid: str, label: str, line: int, file_type: str = "document") -> No

def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
target_file: "str | None" = None) -> None:
target_file: "str | None" = None,
wikilink_name: "str | None" = None) -> None:
edge = {"source": src, "target": tgt, "relation": relation,
"confidence": confidence, "source_file": str_path,
"source_location": f"L{line}", "weight": weight}
if target_file is not None:
edge["target_file"] = target_file
if wikilink_name is not None:
edge["wikilink_name"] = wikilink_name
edges.append(edge)

file_nid = _make_id(str(path))
Expand All @@ -111,7 +114,7 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
# same sibling many times yields one edge, not N (keeps weights meaningful).
linked_targets: set[str] = set()

def add_link(raw: str, line: int) -> None:
def add_link(raw: str, line: int, *, wikilink: bool = False) -> None:
resolved = _resolve_markdown_link(raw, source_dir)
if resolved is None:
return
Expand All @@ -138,7 +141,19 @@ def add_link(raw: str, line: int) -> None:
target_file = str(resolved)
except OSError:
pass
add_edge(file_nid, tgt_nid, "references", line, target_file=target_file)
# Obsidian resolves ``[[name]]`` against the WHOLE vault by filename, not
# against the linking note's directory. A note-type vault (Concepts/,
# Entities/, Guides/, Maps/ ...) therefore links across folders with bare
# names, and sibling-only resolution leaves every one of those dangling.
# When the sibling guess missed, stamp the bare name so extract()'s
# post-pass can repoint the edge against the corpus index. Path-qualified
# markdown links (``[t](./other.md)``) are unaffected: they are relative
# by spec, so sibling resolution is already correct for them.
wl_name = None
if target_file is None and wikilink:
wl_name = resolved.stem
add_edge(file_nid, tgt_nid, "references", line,
target_file=target_file, wikilink_name=wl_name)

# Track heading stack for nesting: [(level, nid), ...]
heading_stack: list[tuple[int, str]] = []
Expand All @@ -164,7 +179,7 @@ def add_link(raw: str, line: int) -> None:
for m in _MD_INLINE_LINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
for m in _MD_WIKILINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
add_link(m.group(1), line_num, wikilink=True)
ref_def = _MD_REF_DEF_RE.match(line_text)
if ref_def:
add_link(ref_def.group(1), line_num)
Expand Down
87 changes: 87 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2241,6 +2241,93 @@ def test_markdown_contains_edges():
assert len(contains_edges) >= 5, f"expected >= 5 contains edges, got {len(contains_edges)}"


def test_markdown_wikilink_stamps_name_when_sibling_missing(tmp_path):
"""A [[name]] with no sibling match carries a wikilink_name stamp.

Obsidian resolves ``[[name]]`` against the whole vault by filename, so a
per-file extractor cannot resolve a cross-folder link on its own. It stamps
the bare name instead, and extract()'s post-pass repoints it.
"""
src_dir = tmp_path / "Concepts"
src_dir.mkdir()
note = src_dir / "alpha.md"
note.write_text("# Alpha\n\nSee [[beta]] and [[alpha-sibling]].\n", encoding="utf-8")
(src_dir / "alpha-sibling.md").write_text("# Sibling\n", encoding="utf-8")

r = extract_markdown(note)
refs = [e for e in r["edges"] if e["relation"] == "references"]
stamped = {e["wikilink_name"] for e in refs if "wikilink_name" in e}
# beta.md is NOT a sibling -> stamped for post-pass resolution.
assert stamped == {"beta"}, f"expected only 'beta' stamped, got {stamped}"
# alpha-sibling.md IS a sibling -> resolved normally, no stamp needed.
assert any("target_file" in e for e in refs)


def test_markdown_path_qualified_link_is_not_stamped(tmp_path):
"""[text](./other.md) is relative by spec - sibling resolution stays correct.

Only wikilinks get the vault-wide fallback; path-qualified markdown links
must keep pure relative semantics.
"""
src_dir = tmp_path / "docs"
src_dir.mkdir()
note = src_dir / "index.md"
note.write_text("# Index\n\n[gone](./nowhere.md)\n", encoding="utf-8")

r = extract_markdown(note)
refs = [e for e in r["edges"] if e["relation"] == "references"]
assert refs, "expected a references edge for the inline link"
assert not any("wikilink_name" in e for e in refs), \
"path-qualified links must not get the wikilink fallback stamp"


def test_extract_resolves_wikilinks_across_folders(tmp_path):
"""Cross-folder [[name]] links resolve against the corpus, not the sibling dir.

A note-type vault (Concepts/, Entities/, ...) links across folders with bare
names. Sibling-only resolution dropped every one of those edges.
"""
from graphify.extract import extract

(tmp_path / "Concepts").mkdir()
(tmp_path / "Entities").mkdir()
src = tmp_path / "Concepts" / "alpha.md"
src.write_text("# Alpha\n\nRefers to [[beta]] and [[missing-note]].\n", encoding="utf-8")
tgt = tmp_path / "Entities" / "beta.md"
tgt.write_text("# Beta\n", encoding="utf-8")

res = extract([src, tgt], cache_root=tmp_path, root=tmp_path, parallel=False)
node_ids = {n["id"] for n in res["nodes"]}
refs = [e for e in res["edges"] if e["relation"] == "references"]

resolved = [e for e in refs if e["target"] in node_ids]
assert len(resolved) == 1, f"cross-folder [[beta]] should resolve: {refs}"
# A link to a file that does not exist anywhere stays dangling, as before.
assert any(e["target"] not in node_ids for e in refs), \
"[[missing-note]] must remain dangling"
# The transient stamp never reaches the caller.
assert not any("wikilink_name" in e for e in res["edges"])


def test_extract_wikilink_prefers_same_folder_on_ambiguity(tmp_path):
"""When two files share a stem, the linking note's own folder wins."""
from graphify.extract import extract, _file_node_id

(tmp_path / "Concepts").mkdir()
(tmp_path / "Entities").mkdir()
src = tmp_path / "Concepts" / "alpha.md"
src.write_text("# Alpha\n\n[[shared]]\n", encoding="utf-8")
near = tmp_path / "Concepts" / "shared.md"
near.write_text("# Near\n", encoding="utf-8")
far = tmp_path / "Entities" / "shared.md"
far.write_text("# Far\n", encoding="utf-8")

res = extract([src, near, far], cache_root=tmp_path, root=tmp_path, parallel=False)
refs = [e for e in res["edges"] if e["relation"] == "references"]
assert len(refs) == 1
assert refs[0]["target"] == _file_node_id(Path("Concepts/shared.md"))


def test_markdown_fenced_heading_not_parsed():
"""A '## heading' inside a fenced block must not produce a heading node (#1077).

Expand Down