Zettelclean
Keep a Zettelkasten filename's slug in sync with the note's H1 heading.
Zettelclean resolves the "three sources of truth for a title" problem (filename vs H1 vs
YAML title:) by separating concerns:
- The H1 heading in the note body is the single source of truth for the human title.
- The filename is a derived machine identity:
TIMEID-slug(H1).md. - No YAML is written. Files stay pure Markdown.
It is a small, one-way, prefix-gated renamer: edit the H1, and a moment later the filename's slug tail follows. Nothing else is touched.
How it works
- A note is a zettel when its filename starts with a recognized prefix: a TIMEID (a run
of 12 to 18 digits) always, and a
yyyy-mm-dddate too if you enable date mode in settings. The prefix is followed by any non-digit or the end of the name - so20260728145404-my-note.md,202607281454.md,202607281454 my note.md, and (date mode)2026-07-30 Journal.mdall qualify. Files without a recognized prefix are ignored, so plain notes, Excalidraw and Kanban files are never renamed. - The prefix is fixed and never changes; only the slug tail is regenerated.
- When you edit the H1, zettelclean waits about 4 seconds of idle, then renames the file to
prefix-slug(H1).mdusing Obsidian's own rename (so backlinks are updated). A 6 second cooldown prevents a just-renamed file from being renamed again. - Sync is strictly one-way, H1 to filename. Renaming a file by hand is never fought.
- Sticky tail: the tail follows the H1 when it produces a slug; with no H1 the existing tail
is kept and cleaned. The tail is never deleted - delete the H1 and the filename keeps its last
slug. A note that never had a title stays bare (
TIMEID.md); one with no usable title (empty H1 and no sluggable tail) is left untouched. - If a rename would collide with an existing filename, zettelclean keeps the current name and shows a notice.
The slug is case-preserving, ASCII, dash-separated: accents are folded (Ciências becomes
Ciencias), runs of punctuation and whitespace collapse to a single dash, and leading and
trailing dashes are trimmed.
Creating notes
Use Obsidian's built-in Unique note creator core plugin to mint the TIMEID:
- Settings -> Core plugins -> enable "Unique note creator".
- Set "Unique note format" to
YYYYMMDDHHmmss(14 digits, second precision - avoids same-minute collisions). - Click the "Create new unique note" ribbon icon. You get
20260728145404.md; type your# Title, and zettelclean takes over from there.
Zettelclean also accepts an existing 12-digit (YYYYMMDDHHmm) vault if you prefer not to
change the setting.
Retrofitting existing notes
Notes created before you adopted this workflow have no TIMEID. Right-click such a note in the file explorer and choose "Prefix timestamp to filename". It mints a fresh timestamp, prepends it, and the note is now a zettel that syncs on future H1 edits.
Date-prefixed notes (journaling)
Turn on date mode in settings to also treat yyyy-mm-dd-prefixed notes as zettels. A daily
note 2026-07-30.md gains a slug once you title it (# Gratitude -> 2026-07-30-Gratitude.md),
and 2026-07-30 Morning pages.md is cleaned to 2026-07-30-Morning-pages.md.
Slugging a folder name
Right-click a folder and choose "Slug folder name" to ASCII-clean it in place (this folder
only): Citações becomes Citacoes. The rename goes through Obsidian, so [[links]] into the
folder are updated. It aborts if a sibling of that name already exists, and never touches the
vault root.
Pretty sidebar (optional)
By default the file explorer shows the slugged filename (with dashes). If you want the sidebar, tabs and graph to show the pretty H1 instead - with no YAML in your files - install the Front Matter Title community plugin and set its feature templates to:
#heading | _basename
That reads the first H1 directly and falls back to the filename when there is none.
Settings
One setting: Recognized filename prefixes (Settings -> Community plugins -> Zettelclean).
- Timestamp only (default) - only 12-18 digit TIMEID prefixes.
- Timestamp and date - also
yyyy-mm-ddprefixes, for journaling.
The 4 second settle and 6 second cooldown are fixed.
Development
Dependencies are managed with pnpm (see "Why pnpm, not npm" below). Node 22+ ships
corepack, so you do not need to install pnpm globally, and the packageManager field in
package.json pins the exact version.
corepack pnpm install # first time, or after a dependency change
corepack pnpm run dev # compile in watch mode
corepack pnpm run build # typecheck and produce main.js
corepack pnpm test # run the slug unit tests (Node's built-in runner)
corepack pnpm run lint # eslint with the Obsidian ruleset
The pure string logic lives in src/slug.ts and is unit-tested in isolation; the Obsidian
wiring lives in src/main.ts. Tests run on Node's built-in test runner (node --test) - there
is no vitest or jest dependency.
Why pnpm, not npm
Do not run npm install or npm ci in this repo. This project is developed on a ZFS
filesystem, and npm's installer renames many directories in parallel (to hoist and dedupe
packages). That races ZFS's directory-metadata handling and aborts with:
npm error code ENOTEMPTY
npm error syscall rename
pnpm avoids the problem entirely: it hard-links packages from a global content-addressable store instead of renaming directories into place, so it installs cleanly on the same disk.
Notes for contributors:
- Only
npm install/npm ciare affected. Running scripts through npm (npm run build) still works, because that just spawns tsc/esbuild and installs nothing. Still, preferpnpmeverywhere for consistency. - The lockfile is
pnpm-lock.yaml(committed). There is nopackage-lock.json; do not create one.
License
GNU General Public License v3.0 or later (GPL-3.0-or-later).
Copyright (C) 2026 Ruben Carlo Benante (Dr. Béco) rcb@beco.cc.
See LICENSE for the full text.