pub fn note_name(key: &str) -> StringExpand description
Map a node key to a filesystem- and wikilink-safe note stem that is unique per key even after case folding.
The name is always <hint>-<16 hex digits>: a lowercased, readable hint
slugged from the key, then an unconditional 64-bit FNV-1a hash of the whole,
exact key. Characters outside [a-z0-9._-] collapse to a single - in the
hint; the hash carries everything the hint threw away.
§Why the hash is unconditional (issue #574)
It used to be applied only when the slug overran the filename limit, and the slug alone was lossy twice over. Measured on this repository — 8,239 nodes rendering to 8,135 notes, 104 of them silently overwritten:
| mechanism | lost | where |
|---|---|---|
every character outside the safe set becomes - and runs collapse, so …cytoscape.min.js#$a and …cytoscape.min.js#a are one name | 9 | everywhere |
macOS and Windows fold filename case, so …#A and …#a are two names but one file | 95 | macOS, Windows |
The second mechanism is the trap. A lossless-but-case-sensitive encoding fixes the 9, verifies clean on Linux CI, and still loses 95 notes on a Mac. So the requirement is stated after folding:
lower(note_name(k1)) == lower(note_name(k2)) implies k1 == k2This matters more than lossiness in a cache would, because the note names are
the vault’s only stable interface: reset_vault_dir deletes and rebuilds
the whole directory on every render, so the one thing that survives a render
is a user’s own note outside the vault linking in by name (issue #442).
§The trade taken
Two decisions, and what each bought:
The hint is lowercased rather than case-preserved. Case-preserving would
also satisfy the requirement — the hash differs for #A and #a, so the two
names differ in their suffix and stay distinct under folding. It was rejected
because lowercasing makes note_name(k) == note_name(k).to_lowercase() an
invariant of the function, and that collapses the folded property into the
literal one: there is then no way to write a version of this that is green on
Linux and lossy on macOS, which is the defect shape this repository keeps
finding. The cost is that parseHTTPHeader reads as parsehttpheader. That
is affordable precisely because the hint is a hint — once a 17-character
suffix is mandatory the name is not something anyone types from memory, so
its job is to be recognisable in a file list, not to be transcribed.
Readability was spent, deliberately. Every name grows by 17 characters and hand-writing a link now needs Obsidian’s autocomplete. The alternatives that keep names short — hashing only the keys observed to collide — make the set of collisions platform-dependent, so one key would get one filename on macOS and another on Linux and a synced vault would churn. A name that is uglier everywhere beats a name that is different per platform.
The mapping is not reversible (the hint is lossy and the hash is one-way), but
it does not need to be: every note’s frontmatter carries key: verbatim, so
name → key is recoverable from the vault itself, which is the direction a
reader actually needs.
§What “unique” rests on
Equal names imply equal hashes, not equal keys — this is a 64-bit hash, not a
proof. Over this repository’s 8,239 keys there is no collision, and the
birthday bound at that size is about 2e-12. Should one ever occur it is
reported, not silent: NoteNames in the render path claims every filename
case-insensitively and warns on a repeat. What is proved outright is the
folding half — the output is lowercase by construction, so case folding is the
identity on it.