diff --git a/graphify/extractors/markdown.py b/graphify/extractors/markdown.py index 1eb7e290e..6c52f9d94 100644 --- a/graphify/extractors/markdown.py +++ b/graphify/extractors/markdown.py @@ -6,14 +6,27 @@ import os import unicodedata from pathlib import Path +from urllib.parse import unquote from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, classify_file from graphify.extractors.base import _file_stem, _make_id, _read_source_text from graphify.security import sanitize_metadata -_MD_INLINE_LINK_RE = re.compile(r'(?]+)>?(?:\s+[^)]*)?\)') +# A link destination is either , the only CommonMark form that may +# contain spaces ([t]()), or a bare run without whitespace. The +# bracketed form is tried first so a spaced name is captured whole instead of +# being cut at the first space; callers take whichever group matched. After the +# `>` it only accepts a CommonMark title ("..", '..' or (..) without a nested +# paren), so an unclosed `<` falls back to the bare form rather than swallowing +# the links that follow on the line. +_MD_INLINE_LINK_RE = re.compile( + r'(?\n]+)>\s*(?:"[^"\n]*"|\'[^\'\n]*\'|\([^()\n]*\))?\s*' + r'|]+)>?(?:\s+[^)]*)?' + r')\)') -_MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*]+)>?') +_MD_REF_DEF_RE = re.compile( + r'^\s{0,3}\[[^\]]+\]:\s*(?:<([^<>\n]+)>(?=\s*(?:["\'(]|$))|]+)>?)') _MD_WIKILINK_RE = re.compile(r'(? "Path | None": return min(matches)[2] +def _percent_decoded(target: str, source_dir: Path) -> str: + """Decode a URL-style link destination such as ``My%20Note.md``. + + Inline and reference links are URLs, so a space or a non-ASCII character + in a file name is written percent-encoded (the form Obsidian writes when + wikilinks are turned off). A file whose name literally contains the + escape keeps precedence (``[x](100%25)`` beside ``100%25.md``), so a + link that resolved before still resolves to the same file. Wikilinks + carry the note name verbatim and never come through here. + """ + if "%" not in target: + return target + decoded = unquote(target) + if decoded == target: + return target + literal = target if Path(target).suffix else target + ".md" + try: + if (source_dir / literal).is_file(): + return target + except OSError: + pass + return decoded + + +def _is_external_link(target: str) -> bool: + low = target.lower() + return "://" in target or low.startswith(("mailto:", "tel:", "//", "data:")) + + def _resolve_markdown_link(raw: str, source_dir: Path, wikilink: bool = False) -> "Path | None": """Resolve a markdown link target to the absolute path of a sibling document. @@ -219,7 +261,9 @@ def _resolve_markdown_link(raw: str, source_dir: Path, The anchor fragment (``#section``) and query (``?x=1``) are stripped before resolution so ``./repo.md#setup`` resolves to the same node as ``./repo.md``. - Extension-less targets (typical of wikilinks) are treated as sibling ``.md``. + Inline and reference destinations are percent-decoded (``My%20Note.md``), + see _percent_decoded. Extension-less targets (typical of wikilinks) are + treated as sibling ``.md``. A wikilink whose note name itself contains a dot (``[[note.en]]``, ``[[v1.2 release]]``) gets the same ``.md`` completion, but only when that document exists and no file graphify indexes sits at the literal target; @@ -238,9 +282,14 @@ def _resolve_markdown_link(raw: str, source_dir: Path, target = target.split("#", 1)[0].split("?", 1)[0].strip() if not target: return None - low = target.lower() - if "://" in target or low.startswith(("mailto:", "tel:", "//", "data:")): + if _is_external_link(target): return None + if not wikilink: + # Decoding can reveal a scheme or a protocol-relative prefix + # (%2F%2Fhost), so the external-link guard runs again. + target = _percent_decoded(target, source_dir) + if _is_external_link(target): + return None suffix = Path(target).suffix.lower() if suffix == "": target = target + ".md" @@ -484,12 +533,12 @@ def extract_markdown(path: Path) -> dict: # non-fenced line (including heading lines, which the heading branch # below `continue`s past) so links anywhere in the doc are captured. for m in _MD_INLINE_LINK_RE.finditer(line_text): - add_link(m.group(1), line_num) + add_link(m.group(1) or m.group(2), line_num) for m in _MD_WIKILINK_RE.finditer(line_text): 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) + add_link(ref_def.group(1) or ref_def.group(2), line_num) # Inside the frontmatter block a leading `#` is a YAML comment, not an # H1. Links above are still scanned there on purpose: review workflows diff --git a/tests/test_languages.py b/tests/test_languages.py index a0e2f67cb..82415aabf 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -3730,6 +3730,68 @@ def test_markdown_dotted_wikilink_literal_file_keeps_precedence(tmp_path): f"an indexed literal file must keep precedence over its .md note: {refs}") +def test_markdown_link_destination_with_spaces(tmp_path): + """A file name with a space is linked as or percent-encoded, + the two CommonMark forms (and Obsidian's markdown-style output). Both + resolve, in inline and reference-style links alike, instead of being cut + at the space or kept encoded and dropped as a ghost.""" + vault = tmp_path / "vault" + (vault / "sub").mkdir(parents=True) + docs = [vault / "My Note.md", vault / "sub" / "Deep Note.md", + vault / "Ref One.md", vault / "Ref Two.md"] + for d in docs: + d.write_text("# Note\n") + entry = vault / "entry.md" + entry.write_text( + "See [a](My%20Note.md), [b]( \"title\"), [c][r1] and [d][r2].\n" + "\n" + "[r1]: \n" + "[r2]: Ref%20Two.md\n") + node_ids, refs, page_id = _vault_extract(vault, docs + [entry]) + targets = {e["target"] for e in refs if e["source"] == page_id(entry)} + assert targets == {page_id(d) for d in docs}, f"spaced link lost: {refs}" + for e in refs: + assert e["target"] in node_ids, f"link target is a ghost node: {e}" + + +def test_markdown_link_forms_keep_their_existing_targets(tmp_path): + """Decoding and bracket parsing take no link away from the file it already + reached: a file whose name literally contains the escape still wins, with + or without the .md ([x](100%25) beside 100%25.md); a wikilink stays + verbatim ([[My%20Note]] is not My Note.md); an unclosed < does not swallow + the links after it on the line, even inside a parenthesised aside.""" + vault = tmp_path / "vault" + vault.mkdir() + literal = vault / "100%25.md" + spaced = vault / "My Note.md" + b, d, f, h = (vault / f"{n}.md" for n in "bdfh") + for doc in (literal, spaced, b, d, f, h): + doc.write_text("# Note\n") + entry = vault / "entry.md" + entry.write_text( + "See [x](100%25.md) and [[My%20Note]].\n" + "Then [a]( [c](d.md).\n" + "And [e]( (see [g](h.md)).\n") + bare = vault / "bare.md" + bare.write_text("See [y](100%25).\n") + node_ids, refs, page_id = _vault_extract( + vault, [literal, spaced, b, d, f, h, entry, bare]) + targets = {e["target"] for e in refs if e["source"] == page_id(entry)} + assert targets & node_ids == {page_id(p) for p in (literal, b, d, f, h)}, ( + f"a link lost the file it reached: {refs}") + bare_targets = {e["target"] for e in refs if e["source"] == page_id(bare)} + assert bare_targets == {page_id(literal)}, ( + f"an extension-less link lost its literal file: {refs}") + + +def test_markdown_decoded_link_stays_external(tmp_path): + """An encoded scheme or protocol-relative prefix (a UNC path on Windows) + is still an external link once decoded, never a local file lookup.""" + from graphify.extractors.markdown import _resolve_markdown_link + for raw in ("%2F%2Fhost%2Fshare%2Fx.md", "https%3A%2F%2Fexample.com%2Fa.md"): + assert _resolve_markdown_link(raw, tmp_path) is None, raw + + # ── Groovy ───────────────────────────────────────────────────────────────────