Files
obsidian-wiki/obsidian_wiki/links.py
T
Codex 82890606aa feat(wiki): sync upstream v2026.10.4 core maintenance
Selectively port source hashing, locked manifest/memory writes, path-aware
link lint and graph analysis, and revision-checked staging from upstream
4f457e154653756b04802d5aeb25c20f42b4770c. Record source hashes and exclusions.

Preserve local archive/catalog citations, curated manifest metadata, config
location, file-index and MCP behavior. Wire maintenance guidance into four
skills without migrating vault contents or changing running services.

Validation: 609 tests passed, including existing MCP/file-asset regressions
and imported upstream maintenance tests. Read-only vault status left shared
metadata hashes unchanged. Wiki MCP remains inactive; orders MCP is active.
2026-10-31 12:39:48 +00:00

161 lines
6.7 KiB
Python

"""One link resolver for every module that builds a graph or checks links.
`graph_analysis`, `graphrag`, and `lint` each identified a page by its slugged
filename stem and resolved a link by its last path segment. Two pages with the
same name — `projects/a/index.md` and `projects/b/index.md`, or the same
concept in two project sub-wikis — therefore became ONE graph node, and a
project-relative link such as `[[concepts/plan]]` written inside `projects/a/`
was reported missing. This module resolves links the way Obsidian does and
gives colliding pages distinct ids, while a vault without collisions keeps
exactly the ids it always had (the slugged stem).
"""
from __future__ import annotations
import posixpath
import re
from collections import defaultdict
from typing import Iterable, Optional
#: Obsidian does not treat a `[[link]]` inside inline code or a fenced block as
#: a link, so neither does anything that walks the link graph.
_FENCE_RE = re.compile(r"^(```|~~~).*?^\1[^\n]*$", re.MULTILINE | re.DOTALL)
# Single-line on purpose: a stray backtick must not swallow the rest of a page.
_INLINE_CODE_RE = re.compile(r"(`+)(?!`).+?(?<!`)\1(?!`)")
def strip_code(text: str) -> str:
"""`text` with fenced code blocks and inline code spans removed."""
return _INLINE_CODE_RE.sub("", _FENCE_RE.sub("", text))
def slug(name: str) -> str:
"""Normalise one page name / path segment the way page ids are keyed."""
return name.strip().lower().replace(" ", "-")
def _key(path: str) -> str:
"""Slugged vault-relative path without `.md`: `Concepts/My Page.md` -> `concepts/my-page`."""
path = path.strip().rstrip("\\")
if path.lower().endswith(".md"):
path = path[:-3]
return "/".join(slug(part) for part in path.strip("/").split("/") if part and part != ".")
def _shared_dirs(a: str, b: str) -> int:
"""How many leading directories two keys have in common."""
count = 0
for left, right in zip(a.split("/")[:-1], b.split("/")[:-1]):
if left != right:
break
count += 1
return count
class PageIndex:
"""Page ids and Obsidian-style link resolution over one set of pages.
A page's id is its slugged stem when no other page shares that stem, and
its slugged vault-relative path otherwise, so ids stay stable for any vault
without name collisions.
"""
def __init__(self, rel_paths: Iterable[str]) -> None:
self._keys: dict[str, str] = {}
self._by_stem: dict[str, list[str]] = defaultdict(list)
# Files whose paths normalise to the same key (`Foo Bar.md` and
# `foo-bar.md`) share one id, as they always have; a link to that key
# is ambiguous because nothing distinguishes them.
self._key_files: dict[str, int] = defaultdict(int)
for rel in rel_paths:
key = _key(rel)
if not key:
continue
self._key_files[key] += 1
if key not in self._keys:
self._keys[key] = rel
self._by_stem[key.rsplit("/", 1)[-1]].append(key)
self._ids = {
key: key.rsplit("/", 1)[-1] if len(self._by_stem[key.rsplit("/", 1)[-1]]) == 1 else key
for key in self._keys
}
def ids(self) -> list[str]:
return list(self._ids.values())
def id_for(self, rel_path: str) -> str:
return self._ids[_key(rel_path)]
def path_for(self, page_id: str) -> Optional[str]:
"""Vault-relative path of an id (or of any unambiguous key)."""
for key, value in self._ids.items():
if value == page_id:
return self._keys[key]
return None
def _nearest(self, candidates: list[str], source_key: str) -> str:
"""The candidate Obsidian would pick from `source_key`: the one sharing
the most folders with the linking page, then the shortest path."""
return min(
candidates,
key=lambda key: (-_shared_dirs(key, source_key) if source_key else 0, key.count("/"), key),
)
def _candidates(self, target: str, source: Optional[str]) -> list:
"""Keys a link could mean, in resolution order: one key when the link
is exact, several when it is a suffix or bare name shared by pages."""
raw = target.strip().rstrip("\\")
source_key = _key(source) if source else ""
source_dir = source_key.rsplit("/", 1)[0] if "/" in source_key else ""
if raw.startswith(("./", "../")):
joined = posixpath.normpath(posixpath.join(source_dir, raw))
key = "" if joined.startswith("..") else _key(joined)
return [key] if key in self._keys else []
key = _key(raw)
if not key:
return []
if "/" in key:
if key in self._keys:
return [key]
local = f"{source_dir}/{key}" if source_dir else key
if local in self._keys:
return [local]
suffixed = [k for k in self._keys if k.endswith(f"/{key}")]
if suffixed:
return suffixed
return list(self._by_stem.get(key.rsplit("/", 1)[-1], []))
def resolve(self, target: str, source: Optional[str] = None) -> Optional[str]:
"""Id of the page a link `target` points at from page `source`, or None.
`target` is the bare link target (no `[[`, alias, or heading anchor).
Order: `./` / `../` relative to the linking file, an exact vault path,
a path relative to the linking file's folder, a path suffix (the
nearest when several match), and finally the bare name (the nearest
page with that stem) so an over-qualified link still resolves.
"""
candidates = self._candidates(target, source)
if not candidates:
return None
return self._ids[self._nearest(candidates, _key(source) if source else "")]
def is_ambiguous(self, target: str, source: Optional[str] = None) -> bool:
"""True when several pages match and none is nearer to `source` than
the rest, so the link's meaning depends on a tie-break."""
candidates = self._candidates(target, source)
if len(candidates) == 1:
return self._key_files[candidates[0]] > 1
if len(candidates) < 2:
return False
source_key = _key(source) if source else ""
scores = sorted((_shared_dirs(key, source_key) for key in candidates), reverse=True)
return scores[0] == scores[1]
def match(self, term: str) -> Optional[str]:
"""Id for a free-text page reference (a CLI argument): an id, a path, or
a bare name. A bare name shared by several pages picks the shortest path."""
key = _key(term)
if key in self._ids.values():
return key
return self.resolve(term)