Skip to content

to_obsidian: all-punctuation labels emit punctuation-only filenames (e.g. @.md) that break downstream slug/indexers (qmd handelize crash) #1409

Description

@Mylock51

Summary

to_obsidian()'s safe_name() can emit a note whose filename is only punctuation (e.g. @.md) when a node label is all-punctuation after the unsafe-char strip (e.g. a @/* node extracted from a tsconfig.json paths entry). Such a filename is valid on disk, but any downstream tool that re-slugs the stem on word characters reduces it to empty and fails.

Concretely, it crashes qmd (the Quick Markdown Search CLI) on qmd update, because its handelize() reduces @"" and raises:

Error: handelize: path "graphify-out/obsidian/@.md" has no valid filename content
    at handelize (.../@tobilu/qmd/dist/store.js:1493:15)
    at reindexCollection (.../store.js:969:22)

Since qmd update indexes collections in a batch, this one generated file aborts the entire run — every other collection on the machine stops being reindexed too. Found on graphifyy==0.8.33.

Root cause

In graphify/export.py, safe_name() (used in both the node export and the community export of to_obsidian):

def safe_name(label: str) -> str:
    cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label. ...).strip()
    cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
    return _cap_filename(cleaned) if cleaned else "unnamed"   # <-- "@" is truthy, slips through

For label @/*: the unsafe-char strip removes / and *, leaving @. @ is non-empty, so the else "unnamed" fallback never fires, and the stem becomes @ → file @.md.

Proposed fix

Require at least one word character; otherwise fall back (so an all-punctuation label can never produce a punctuation-only filename):

    cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
    # A stem of only punctuation ("@", "*", "#") survives the unsafe-char strip but
    # is empty once a downstream tool re-slugs on word chars (e.g. qmd's handelize()
    # → crash). Require at least one word char; else fall back.
    if not re.search(r"\w", cleaned, flags=re.UNICODE):
        return "unnamed"
    return _cap_filename(cleaned)

Applies to both occurrences of safe_name in export.py. The existing dedup logic already handles multiple unnamed collisions (unnamed_1, …).

Repro

A node whose label is all punctuation after the unsafe-char strip (e.g. @/* from a tsconfig.json paths key) → graphify-out/obsidian/@.md. Other @*.md files with a word char (@types/node@typesnode.md) are fine; only the bare-punctuation case breaks.

Happy to send a PR if the fix looks right.

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