Skip to content

export wiki: links are percent-encoded but files are written raw, so every article with ( ) & or non-ASCII in its label is linked to a nonexistent path #2597

Description

@ericziko

Summary

graphify export wiki writes article files under the raw slug but links to them under the
percent-encoded slug, so any article whose label contains (, ), & or a non-ASCII character
is linked at a path that does not exist on disk.

Renderers hide this (GitHub/VS Code decode %28 before resolving), but the wiki's stated purpose is
to be agent-crawlable — and an agent that reads the link target verbatim off disk gets
FileNotFoundError.

On my repo: 12 of 105 inter-article links are broken as written (6 of them in index.md).
All 12 resolve after urllib.parse.unquote, and 0 targets are genuinely missing — so this is purely
a filename/link mismatch, not lost content.

This looks like a sequel to #1444: that fix correctly moved off [[wikilinks]] to standard markdown
links and added quote() for renderer safety, but the on-disk name was not normalized to match.

Version

graphify (graphifyy) 0.9.38, Python 3.13, Linux.

Root cause

graphify/wiki.py:

# line 12 - produces the ON-DISK name. Only substitutes Windows-reserved chars;
# ( ) & and non-ASCII pass through untouched.
def _safe_filename(name: str) -> str:
    s = name.replace("/", "-").replace(" ", "_").replace(":", "-")
    s = re.sub(r'[<>:"/\\|?*]', '_', s)
    s = s.strip('. ')
    return s[:200] if s else 'unnamed'

# line 48 - produces the LINK, quoting the same slug.
# urllib.parse.quote(safe='/') encodes ( -> %28, ) -> %29, & -> %26, em-dash -> %E2%80%94
return f"[{text}]({quote(f'{slug}.md')})"

The file is written as {slug}.md (raw) while the link is quote(f'{slug}.md'). The two agree only
when the slug happens to be URL-safe.

Repro

Any node label containing parens, &, or an em-dash. Real examples from my run:

Link emitted File actually written
load_traumas%28%29.md load_traumas().md
find_repo_root%28%29.md find_repo_root().md
check_command%28%29.md check_command().md
snapshot_db%28%29.md snapshot_db().md
Forgejo_upgrade_%26_rollback_%28runbook%29.md Forgejo_upgrade_&_rollback_(runbook).md
Tailscale_HTTPS_endpoints_%E2%80%94_how_services_get_their_URLs.md Tailscale_HTTPS_endpoints_—_how_services_get_their_URLs.md

Function-named nodes (foo()) make this common for any code repo.

Check on an existing wiki:

import re, os, urllib.parse
for f in filter(lambda p: p.endswith('.md'), os.listdir('graphify-out/wiki')):
    for l in set(re.findall(r'\]\(([^)]+\.md)\)', open(f'graphify-out/wiki/{f}').read())):
        if not os.path.exists(f'graphify-out/wiki/{l}'):
            print(f, '->', l, '| decoded exists:',
                  os.path.exists(f'graphify-out/wiki/{urllib.parse.unquote(l)}'))

Suggested fix

Preferred — normalize the filename so quote() is a no-op. Make _safe_filename also replace
(, ), & and non-ASCII, so the slug is URL-safe by construction and the link and the file are
the same string. One source of truth, and existing quote() stays as harmless defense.

Note this cannot be fixed by simply dropping quote(): an unescaped ) terminates a CommonMark
link destination, so [x](load_traumas().md) would parse as a link to load_traumas( — worse than
today. (Spaces are already handled, since _safe_filename maps them to _.)

Alternative — angle-bracket destinations: emit [text](<load_traumas().md>). CommonMark allows
parens and spaces inside <...>, so the link matches the on-disk name exactly and filenames stay
human-readable. Worth checking Obsidian renders these before choosing it.

Either way, a cheap regression guard would be an assertion in to_wiki() that every emitted link
target exists on disk before returning — that turns this whole class of bug into a hard failure at
export time.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions