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
Summary
graphify export wikiwrites article files under the raw slug but links to them under thepercent-encoded slug, so any article whose label contains
(,),&or a non-ASCII characteris linked at a path that does not exist on disk.
Renderers hide this (GitHub/VS Code decode
%28before resolving), but the wiki's stated purpose isto 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 purelya filename/link mismatch, not lost content.
This looks like a sequel to #1444: that fix correctly moved off
[[wikilinks]]to standard markdownlinks 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:The file is written as
{slug}.md(raw) while the link isquote(f'{slug}.md'). The two agree onlywhen the slug happens to be URL-safe.
Repro
Any node label containing parens,
&, or an em-dash. Real examples from my run:load_traumas%28%29.mdload_traumas().mdfind_repo_root%28%29.mdfind_repo_root().mdcheck_command%28%29.mdcheck_command().mdsnapshot_db%28%29.mdsnapshot_db().mdForgejo_upgrade_%26_rollback_%28runbook%29.mdForgejo_upgrade_&_rollback_(runbook).mdTailscale_HTTPS_endpoints_%E2%80%94_how_services_get_their_URLs.mdTailscale_HTTPS_endpoints_—_how_services_get_their_URLs.mdFunction-named nodes (
foo()) make this common for any code repo.Check on an existing wiki:
Suggested fix
Preferred — normalize the filename so
quote()is a no-op. Make_safe_filenamealso replace(,),&and non-ASCII, so the slug is URL-safe by construction and the link and the file arethe 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 CommonMarklink destination, so
[x](load_traumas().md)would parse as a link toload_traumas(— worse thantoday. (Spaces are already handled, since
_safe_filenamemaps them to_.)Alternative — angle-bracket destinations: emit
[text](<load_traumas().md>). CommonMark allowsparens and spaces inside
<...>, so the link matches the on-disk name exactly and filenames stayhuman-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 linktarget exists on disk before returning — that turns this whole class of bug into a hard failure at
export time.
Related
graphify export wikiemits Obsidian[[wikilinks]]that break in every non-Obsidian renderer #1444 — moved to standard markdown links +quote(); this is the unfinished half of it._safe_filenamecollisions causing silent overwrites; same function, adjacent concern.