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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions