5 Commits
v0.3 ... master

Author SHA1 Message Date
f6bcd80b18 Release version 0.6.1
Some checks failed
Zettelclean CI / build (20.x) (push) Has been cancelled
Zettelclean CI / build (22.x) (push) Has been cancelled
Zettelclean CI / build (24.x) (push) Has been cancelled
Release Zettelclean / build (push) Has been cancelled
2026-07-31 09:18:34 -03:00
c9e6c49027 .gitignore ZETTEL files
Some checks failed
Zettelclean CI / build (20.x) (push) Has been cancelled
Zettelclean CI / build (22.x) (push) Has been cancelled
Zettelclean CI / build (24.x) (push) Has been cancelled
2026-07-31 09:00:31 -03:00
adcb9e5d88 date-prefix and sticky tail v0.6 for Obsidian v1.12.7 and v1.13.0 (min v1.0.0)
Some checks failed
Zettelclean CI / build (20.x) (push) Has been cancelled
Zettelclean CI / build (22.x) (push) Has been cancelled
Zettelclean CI / build (24.x) (push) Has been cancelled
Release Zettelclean / build (push) Has been cancelled
2026-07-30 18:16:52 -03:00
35cb6dd976 rename folder feature added. 4s edit wait, 6s cooldown adjust
Some checks failed
Zettelclean CI / build (20.x) (push) Has been cancelled
Zettelclean CI / build (22.x) (push) Has been cancelled
Zettelclean CI / build (24.x) (push) Has been cancelled
Release Zettelclean / build (push) Has been cancelled
2026-07-29 11:07:30 -03:00
e83b95c6ee pnpm corepack, CI bugfix and no warnings for TIMEID
Some checks failed
Zettelclean CI / build (20.x) (push) Has been cancelled
Zettelclean CI / build (22.x) (push) Has been cancelled
Zettelclean CI / build (24.x) (push) Has been cancelled
Release Zettelclean / build (push) Has been cancelled
2026-07-28 21:27:47 -03:00
13 changed files with 361 additions and 902 deletions

View File

@@ -42,7 +42,11 @@ jobs:
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'npm' # Node ships corepack, which selects the pnpm version pinned in
- run: npm ci # package.json ("packageManager"). If this self-hosted runner has no
- run: npm run build --if-present # Node, drop the setup-node step above and rely on a preinstalled Node.
- run: npm run lint - run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run lint
- run: pnpm test

View File

@@ -40,12 +40,17 @@ jobs:
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
cache: 'npm'
# Node ships corepack, which selects the pnpm version pinned in
# package.json ("packageManager"). If this self-hosted runner has no
# Node, drop the setup-node step above and rely on a preinstalled Node.
- name: Enable pnpm
run: corepack enable
- name: Build plugin - name: Build plugin
run: | run: |
npm ci pnpm install --frozen-lockfile
npm run build pnpm run build
- name: Check for optional styles - name: Check for optional styles
id: styles id: styles

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ data.json
# Exclude macOS Finder (System Explorer) View States # Exclude macOS Finder (System Explorer) View States
.DS_Store .DS_Store
ZETTELCLEAN*

View File

@@ -37,18 +37,26 @@ slug tail follows. Nothing else is touched.
## How it works ## How it works
- A note is a zettel when its filename starts with a **TIMEID**: a run of 12 to 18 digits - A note is a zettel when its filename starts with a recognized **prefix**: a **TIMEID** (a run
(a timestamp), for example `20260728145404-my-note.md`. Files without that prefix are of 12 to 18 digits) always, and a **`yyyy-mm-dd` date** too if you enable date mode in settings.
ignored, so plain notes, daily notes, Excalidraw and Kanban files are never renamed. The prefix is followed by any non-digit or the end of the name - so `20260728145404-my-note.md`,
- The TIMEID is minted **once** and never changes. Only the slug tail is regenerated. `202607281454.md`, `202607281454 my note.md`, and (date mode) `2026-07-30 Journal.md` all
- When you edit the H1, zettelclean waits about 5 seconds of idle, then renames the file to qualify. Files without a recognized prefix are ignored, so plain notes, Excalidraw and Kanban
`TIMEID-slug(H1).md` using Obsidian's own rename (so backlinks are updated). A 10 second 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).md` using Obsidian's own rename (so backlinks are updated). A 6 second
cooldown prevents a just-renamed file from being renamed again. 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. - Sync is strictly one-way, H1 to filename. Renaming a file by hand is never fought.
- Delete the H1 and the filename collapses back to the bare `TIMEID.md`. - **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 lowercase, ASCII, dash-separated: accents are folded (`Ciências` becomes 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 `Ciencias`), runs of punctuation and whitespace collapse to a single dash, and leading and
trailing dashes are trimmed. trailing dashes are trimmed.
## Creating notes ## Creating notes
@@ -67,9 +75,22 @@ change the setting.
### Retrofitting existing notes ### Retrofitting existing notes
Notes created before you adopted this workflow have no TIMEID. Right-click such a note in the Notes created before you adopted this workflow have no TIMEID. Right-click such a note in the
file explorer and choose **"Add zettel TIMEID to filename"**. It mints a fresh timestamp, 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. 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) ## Pretty sidebar (optional)
By default the file explorer shows the slugged filename (with dashes). If you want the sidebar, By default the file explorer shows the slugged filename (with dashes). If you want the sidebar,
@@ -84,19 +105,52 @@ That reads the first H1 directly and falls back to the filename when there is no
## Settings ## Settings
None. Zettelclean is zero-config in this version. The 5 second settle and 10 second cooldown One setting: **Recognized filename prefixes** (Settings -> Community plugins -> Zettelclean).
are fixed.
- **Timestamp only** (default) - only 12-18 digit TIMEID prefixes.
- **Timestamp and date** - also `yyyy-mm-dd` prefixes, for journaling.
The 4 second settle and 6 second cooldown are fixed.
## Development ## Development
- `npm install` (or `pnpm install`). Dependencies are managed with **pnpm** (see "Why pnpm, not npm" below). Node 22+ ships
- `npm run dev` - compile in watch mode. `corepack`, so you do not need to install pnpm globally, and the `packageManager` field in
- `npm run build` - typecheck and produce `main.js`. `package.json` pins the exact version.
- `npm test` - run the slug unit tests on Node's built-in test runner.
- `npm run lint` - eslint with the Obsidian ruleset. ```bash
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 The pure string logic lives in `src/slug.ts` and is unit-tested in isolation; the Obsidian
wiring lives in `src/main.ts`. 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 ci` are affected. Running *scripts* through npm (`npm run build`)
still works, because that just spawns tsc/esbuild and installs nothing. Still, prefer `pnpm`
everywhere for consistency.
- The lockfile is `pnpm-lock.yaml` (committed). There is no `package-lock.json`; do not create
one.
## License ## License

View File

@@ -1 +1 @@
zettelclean v0.3 zettelclean v0.6.1

View File

@@ -1,7 +1,7 @@
{ {
"id": "zettelclean", "id": "zettelclean",
"name": "Zettelclean", "name": "Zettelclean",
"version": "0.3.0", "version": "0.6.1",
"minAppVersion": "1.0.0", "minAppVersion": "1.0.0",
"description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.", "description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.",
"author": "Ruben Carlo Benante", "author": "Ruben Carlo Benante",

View File

@@ -1,10 +1,11 @@
{ {
"name": "zettelclean", "name": "zettelclean",
"version": "0.3.0", "version": "0.6.1",
"description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.", "description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.",
"author": "Ruben Carlo Benante <rcb@beco.cc>", "author": "Ruben Carlo Benante <rcb@beco.cc>",
"main": "main.js", "main": "main.js",
"type": "module", "type": "module",
"packageManager": "pnpm@9.15.9",
"scripts": { "scripts": {
"dev": "node esbuild.config.mjs", "dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",

840
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,29 +19,52 @@
// * rcb@beco.cc * // * rcb@beco.cc *
// ************************************************************************* // *************************************************************************
import { Plugin, TFile, TAbstractFile, Menu, moment } from 'obsidian'; import {
import { extractTimeId, generateSlug, buildFilename } from './slug'; Plugin,
TFile,
TAbstractFile,
TFolder,
Menu,
Notice,
moment,
} from 'obsidian';
import {
extractPrefix,
computeBasename,
generateSlug,
buildFilename,
} from './slug';
import {
ZettelcleanSettings,
DEFAULT_SETTINGS,
ZettelcleanSettingTab,
} from './settings';
const SETTLE_MS = 5000; // rename ~5s after the H1 stops changing const SETTLE_MS = 4000; // rename ~4s after the H1 stops changing
const COOLDOWN_MS = 10000; // do not rename the same path again within 10s const COOLDOWN_MS = 6000; // do not rename the same path again within 6s
export default class ZettelcleanPlugin extends Plugin { export default class ZettelcleanPlugin extends Plugin {
settings!: ZettelcleanSettings;
private renameTimers = new Map<string, number>(); private renameTimers = new Map<string, number>();
private lastRenamedAt = new Map<string, number>(); private lastRenamedAt = new Map<string, number>();
private isRenameInProgress = false; private isRenameInProgress = false;
async onload() { async onload() {
await this.loadSettings();
this.addSettingTab(new ZettelcleanSettingTab(this.app, this));
// Fires after the metadata cache reparses a note, so headings[] is fresh. // Fires after the metadata cache reparses a note, so headings[] is fresh.
this.registerEvent( this.registerEvent(
this.app.metadataCache.on('changed', (file) => this.app.metadataCache.on('changed', (file) =>
this.scheduleSync(file), this.scheduleSync(file),
), ),
); );
// Retrofit entry in the file-explorer right-click menu, near "Rename". // File-explorer right-click menu: retrofit item for files, slug item for folders.
this.registerEvent( this.registerEvent(
this.app.workspace.on('file-menu', (menu, file) => this.app.workspace.on('file-menu', (menu, file) => {
this.addTimeIdMenu(menu, file), this.addTimeIdMenu(menu, file);
), this.addFolderSlugMenu(menu, file);
}),
); );
} }
@@ -50,22 +73,35 @@ export default class ZettelcleanPlugin extends Plugin {
this.renameTimers.clear(); this.renameTimers.clear();
} }
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
(await this.loadData()) as Partial<ZettelcleanSettings>,
);
}
async saveSettings() {
await this.saveData(this.settings);
}
private pathFor(file: TFile, base: string): string { private pathFor(file: TFile, base: string): string {
const dir = const dir =
file.parent && file.parent.path !== '/' ? `${file.parent.path}/` : ''; file.parent && file.parent.path !== '/' ? `${file.parent.path}/` : '';
return `${dir}${base}.md`; return `${dir}${base}.md`;
} }
// Guard layer: only the actively edited zettel, honoring the 10s cooldown, // Guard layer: only the actively edited zettel, honoring the 6s cooldown,
// (re)arming the 5s settle timer on every cache change. // (re)arming the 4s settle timer on every cache change.
private scheduleSync(file: TAbstractFile) { private scheduleSync(file: TAbstractFile) {
if (this.isRenameInProgress) return; // our own rename, ignore if (this.isRenameInProgress) return; // our own rename, ignore
if (!(file instanceof TFile) || file.extension !== 'md') return; if (!(file instanceof TFile) || file.extension !== 'md') return;
if (extractTimeId(file.basename) === null) return; // opt-in gate: no TIMEID const allowDate = this.settings.prefixMode === 'date';
if (extractPrefix(file.basename, allowDate) === null) return; // opt-in gate
if (this.app.workspace.getActiveFile() !== file) return; // only the edited note if (this.app.workspace.getActiveFile() !== file) return; // only the edited note
const last = this.lastRenamedAt.get(file.path); const last = this.lastRenamedAt.get(file.path);
if (last && Date.now() - last < COOLDOWN_MS) return; // 10s cooldown if (last && Date.now() - last < COOLDOWN_MS) return; // 6s cooldown
const existing = this.renameTimers.get(file.path); const existing = this.renameTimers.get(file.path);
if (existing) window.clearTimeout(existing); if (existing) window.clearTimeout(existing);
@@ -78,24 +114,27 @@ export default class ZettelcleanPlugin extends Plugin {
); );
} }
// Compute the target basename, bail if unchanged, otherwise rename. // Compute the target basename via the sticky rule, bail if unchanged, else rename.
private async syncNow(file: TFile) { private async syncNow(file: TFile) {
const id = extractTimeId(file.basename);
if (id === null) return; // prefix removed meanwhile
const first = this.app.metadataCache.getFileCache(file)?.headings?.[0]; const first = this.app.metadataCache.getFileCache(file)?.headings?.[0];
const h1 = first && first.level === 1 ? first.heading : ''; const h1 = first && first.level === 1 ? first.heading : '';
const allowDate = this.settings.prefixMode === 'date';
const newBase = buildFilename(id, generateSlug(h1)); const newBase = computeBasename(file.basename, h1, allowDate);
if (newBase === file.basename) return; // nothing to do if (newBase === null || newBase === file.basename) return; // unrecognized or no change
const newPath = this.pathFor(file, newBase); const newPath = this.pathFor(file, newBase);
if (this.app.vault.getAbstractFileByPath(newPath)) {
new Notice(`Can't rename to "${newBase}.md" - that name already exists`);
return; // conflict: keep current name, tell the user
}
this.isRenameInProgress = true; this.isRenameInProgress = true;
try { try {
await this.app.fileManager.renameFile(file, newPath); await this.app.fileManager.renameFile(file, newPath);
this.lastRenamedAt.set(newPath, Date.now()); this.lastRenamedAt.set(newPath, Date.now());
} catch (e) { } catch (e) {
console.error('zettelclean: rename failed', e); console.error('zettelclean: rename failed', e);
new Notice('Rename failed (see console)');
} finally { } finally {
this.isRenameInProgress = false; this.isRenameInProgress = false;
} }
@@ -103,10 +142,11 @@ export default class ZettelcleanPlugin extends Plugin {
private addTimeIdMenu(menu: Menu, file: TAbstractFile) { private addTimeIdMenu(menu: Menu, file: TAbstractFile) {
if (!(file instanceof TFile) || file.extension !== 'md') return; if (!(file instanceof TFile) || file.extension !== 'md') return;
if (extractTimeId(file.basename) !== null) return; // already a zettel const allowDate = this.settings.prefixMode === 'date';
if (extractPrefix(file.basename, allowDate) !== null) return; // already a zettel
menu.addItem((item) => menu.addItem((item) =>
item item
.setTitle('Add zettel TIMEID to filename') .setTitle('Prefix timestamp to filename')
.setIcon('clock') .setIcon('clock')
.onClick(() => void this.addTimeId(file)), .onClick(() => void this.addTimeId(file)),
); );
@@ -128,4 +168,32 @@ export default class ZettelcleanPlugin extends Plugin {
} }
await this.app.fileManager.renameFile(file, target); await this.app.fileManager.renameFile(file, target);
} }
// Folder right-click: slug the folder's own name (this folder only, instant).
private addFolderSlugMenu(menu: Menu, file: TAbstractFile) {
if (!(file instanceof TFolder) || file.isRoot()) return; // never the vault root
menu.addItem((item) =>
item
.setTitle('Slug folder name')
.setIcon('folder')
.onClick(() => void this.slugFolder(file)),
);
}
private async slugFolder(folder: TFolder) {
const newName = generateSlug(folder.name);
if (!newName || newName === folder.name) {
new Notice('Folder name is already clean');
return;
}
const parent = folder.parent;
const dir = parent && parent.path !== '/' ? `${parent.path}/` : '';
const newPath = `${dir}${newName}`; // folders have no extension
if (this.app.vault.getAbstractFileByPath(newPath)) {
new Notice(`Cannot rename: "${newName}" already exists`);
return;
}
await this.app.fileManager.renameFile(folder, newPath); // updates [[links]]
new Notice(`Renamed folder "${folder.name}" -> "${newName}"`);
}
} }

94
src/settings.ts Normal file
View File

@@ -0,0 +1,94 @@
// *************************************************************************
// * (C)opyright 2026 by Ruben Carlo Benante *
// * *
// * This program is free software; you can redistribute it and/or modify *
// * it under the terms of the GNU General Public License as published by *
// * the Free Software Foundation, either version 3 of the License, or *
// * (at your option) any later version. *
// * *
// * This program is distributed in the hope that it will be useful, *
// * but WITHOUT ANY WARRANTY; without even the implied warranty of *
// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
// * GNU General Public License for more details. *
// * *
// * You should have received a copy of the GNU General Public License *
// * along with this program. If not, see http://www.gnu.org/licenses/. *
// * *
// * Contact author at: *
// * Ruben Carlo Benante *
// * rcb@beco.cc *
// *************************************************************************
import { App, PluginSettingTab, Setting, SettingDefinitionItem } from 'obsidian';
import ZettelcleanPlugin from './main';
export type PrefixMode = 'timeid' | 'date';
export interface ZettelcleanSettings {
prefixMode: PrefixMode;
}
export const DEFAULT_SETTINGS: ZettelcleanSettings = {
prefixMode: 'timeid',
};
const PREFIX_LABELS: Record<string, string> = {
timeid: 'Timestamp only (12-18 digits)',
date: 'Timestamp and date (yyyy-mm-dd)',
};
export class ZettelcleanSettingTab extends PluginSettingTab {
plugin: ZettelcleanPlugin;
constructor(app: App, plugin: ZettelcleanPlugin) {
super(app, plugin);
this.plugin = plugin;
}
// Modern declarative settings (Obsidian 1.13+): the tab renders and indexes
// these for settings search. Ignored on older versions, which use display().
getSettingDefinitions(): SettingDefinitionItem[] {
return [
{
name: 'Recognized filename prefixes',
desc: 'Which leading prefixes mark a note for slug syncing.',
control: {
type: 'dropdown',
key: 'prefixMode',
defaultValue: 'timeid',
options: PREFIX_LABELS,
},
},
];
}
getControlValue(key: string): unknown {
return this.plugin.settings[key as keyof ZettelcleanSettings];
}
async setControlValue(key: string, value: unknown): Promise<void> {
this.plugin.settings[key as keyof ZettelcleanSettings] =
value as PrefixMode;
await this.plugin.saveSettings();
}
// Imperative fallback for Obsidian < 1.13 (e.g. 1.12.x). Not called on 1.13+
// once getSettingDefinitions() returns a non-empty array.
display(): void {
this.containerEl.empty();
new Setting(this.containerEl)
.setName('Recognized filename prefixes')
.setDesc('Which leading prefixes mark a note for slug syncing.')
.addDropdown((d) => {
for (const [value, label] of Object.entries(PREFIX_LABELS)) {
d.addOption(value, label);
}
d.setValue(this.plugin.settings.prefixMode).onChange(
async (value) => {
this.plugin.settings.prefixMode = value as PrefixMode;
await this.plugin.saveSettings();
},
);
});
}
}

View File

@@ -21,17 +21,24 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { extractTimeId, generateSlug, buildFilename } from './slug.ts'; import {
extractTimeId,
extractPrefix,
computeBasename,
generateSlug,
buildFilename,
} from './slug.ts';
test('slug folds accents, lowercases, collapses', () => { test('slug folds accents and collapses, preserving case', () => {
assert.equal( assert.equal(
generateSlug('Ciências Políticas - Visão Geral'), generateSlug('Ciências Políticas - Visão Geral'),
'ciencias-politicas-visao-geral', 'Ciencias-Politicas-Visao-Geral',
); );
assert.equal(generateSlug('Citações'), 'Citacoes');
}); });
test('slug collapses runs of punctuation and whitespace to one dash', () => { test('slug collapses runs of punctuation and whitespace to one dash', () => {
assert.equal(generateSlug(' Hello, World!! '), 'hello-world'); assert.equal(generateSlug(' Hello, World!! '), 'Hello-World');
assert.equal(generateSlug('a/b:c*d?e'), 'a-b-c-d-e'); assert.equal(generateSlug('a/b:c*d?e'), 'a-b-c-d-e');
}); });
@@ -40,11 +47,13 @@ test('empty or heading-less input yields empty slug', () => {
assert.equal(generateSlug(' --- '), ''); assert.equal(generateSlug(' --- '), '');
}); });
test('extractTimeId accepts 12/14/18-digit prefixes, rejects others', () => { test('extractTimeId accepts 12/14/18-digit prefixes and a non-dash boundary', () => {
assert.equal(extractTimeId('202607281454-x'), '202607281454'); // 12 assert.equal(extractTimeId('202607281454-x'), '202607281454'); // 12, dash
assert.equal(extractTimeId('20260728145404-x'), '20260728145404'); // 14 assert.equal(extractTimeId('20260728145404-x'), '20260728145404'); // 14
assert.equal(extractTimeId('202607281454049999-x'), '202607281454049999'); // 18 assert.equal(extractTimeId('202607281454049999-x'), '202607281454049999'); // 18
assert.equal(extractTimeId('20260728145404'), '20260728145404'); // bare id assert.equal(extractTimeId('20260728145404'), '20260728145404'); // bare id
assert.equal(extractTimeId('202607290947 test pkm'), '202607290947'); // space
assert.equal(extractTimeId('202607290947test'), '202607290947'); // letters
assert.equal(extractTimeId('my-note'), null); assert.equal(extractTimeId('my-note'), null);
assert.equal(extractTimeId('2026-budget'), null); // too short assert.equal(extractTimeId('2026-budget'), null); // too short
assert.equal(extractTimeId('2026072814540499999-x'), null); // 19, too long assert.equal(extractTimeId('2026072814540499999-x'), null); // 19, too long
@@ -57,3 +66,31 @@ test('buildFilename collapses empty slug to bare id', () => {
); );
assert.equal(buildFilename('20260728145404', ''), '20260728145404'); assert.equal(buildFilename('20260728145404', ''), '20260728145404');
}); });
test('extractPrefix: timeid always, date only when allowed, dash does not split', () => {
assert.equal(extractPrefix('202607302233 vai', false), '202607302233'); // timeid
assert.equal(extractPrefix('2026-07-30-Journal', true), '2026-07-30'); // date, dash kept
assert.equal(extractPrefix('2026-07-30 Journal', true), '2026-07-30');
assert.equal(extractPrefix('2026-07-30 Journal', false), null); // date off
assert.equal(extractPrefix('my-note', true), null);
});
test('computeBasename: sticky tail rule', () => {
// H1 sets the tail
assert.equal(computeBasename('202607302233', 'Home', false), '202607302233-Home');
// delete H1 keeps the last slug (sticky)
assert.equal(computeBasename('202607302233-Home', '', false), '202607302233-Home');
// no H1: clean the existing tail
assert.equal(computeBasename('202607291108 vai', '', false), '202607291108-vai');
// punctuation-only H1: keep the existing tail
assert.equal(computeBasename('2026-07-30-Home', '!!!', true), '2026-07-30-Home');
// bare stays bare
assert.equal(computeBasename('202607302233', '', false), '202607302233');
// no usable title -> left untouched
assert.equal(computeBasename('2026-07-30 !!!', '', true), '2026-07-30 !!!');
// date mode gating
assert.equal(computeBasename('2026-07-30 Journal', '', true), '2026-07-30-Journal');
assert.equal(computeBasename('2026-07-30 Journal', '', false), null);
// unrecognized note
assert.equal(computeBasename('my-note', 'Title', true), null);
});

View File

@@ -23,9 +23,10 @@
// in isolation. The transform is always one-way (H1 -> filename), which lets us // in isolation. The transform is always one-way (H1 -> filename), which lets us
// freely drop characters without worrying about a reverse mapping. // freely drop characters without worrying about a reverse mapping.
// A TIMEID is a leading run of 12 to 18 digits, ending at a dash or end-of-name. // A TIMEID is the leading run of 12 to 18 digits, ending at any non-digit or
// end-of-name (so "202607290947 test" and "202607290947-slug" both qualify).
// 12-18 because collision-avoidance may append extra digits to a 14-digit stamp. // 12-18 because collision-avoidance may append extra digits to a 14-digit stamp.
const TIMEID_RE = /^(\d{12,18})(?=-|$)/; const TIMEID_RE = /^(\d{12,18})(?=\D|$)/;
// Return the immutable id prefix, or null if this file is not a zettel. // Return the immutable id prefix, or null if this file is not a zettel.
export function extractTimeId(basename: string): string | null { export function extractTimeId(basename: string): string | null {
@@ -33,13 +34,30 @@ export function extractTimeId(basename: string): string | null {
return m?.[1] ?? null; return m?.[1] ?? null;
} }
// H1 text -> kebab slug. sanifize as inspiration, deliberately simpler. // A date prefix is yyyy-mm-dd, ending at any non-digit or end-of-name. Fixed digit
export function generateSlug(heading: string): string { // counts with literal dashes, so it captures exactly the date and never breaks at the
return heading // first dash.
const DATE_RE = /^(\d{4}-\d{2}-\d{2})(?=\D|$)/;
// The immutable prefix: a TIMEID always; a yyyy-mm-dd date too when allowDate. null if
// unrecognized. A TIMEID is pure digits and a date has dashes, so the two are disjoint.
export function extractPrefix(
basename: string,
allowDate: boolean,
): string | null {
const t = extractTimeId(basename);
if (t) return t;
if (allowDate) return basename.match(DATE_RE)?.[1] ?? null;
return null;
}
// text (an H1 or a folder name) -> ASCII, case-preserving, dash-separated slug.
// Shared by the file-rename sync and the folder-slug menu.
export function generateSlug(text: string): string {
return text
.normalize('NFKD') // split accented letters into base + combining mark .normalize('NFKD') // split accented letters into base + combining mark
.replace(/[̀-ͯ]/g, '') // strip the combining marks (c-cedilla -> c) .replace(/[̀-ͯ]/g, '') // strip the combining marks (c-cedilla -> c)
.toLowerCase() .replace(/[^a-zA-Z0-9]+/g, '-') // non-alphanumerics -> one dash (case kept)
.replace(/[^a-z0-9]+/g, '-') // any run of non-alphanumerics -> one dash
.replace(/^-+|-+$/g, ''); // trim leading/trailing dashes .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes
} }
@@ -47,3 +65,19 @@ export function generateSlug(heading: string): string {
export function buildFilename(id: string, slug: string): string { export function buildFilename(id: string, slug: string): string {
return slug ? `${id}-${slug}` : id; return slug ? `${id}-${slug}` : id;
} }
// Sticky rule. The target basename for a recognized note, or null if it is not a zettel.
// The tail follows the H1 when that yields a slug; otherwise the existing filename tail is
// kept and cleaned; a name with no usable title is left untouched (never collapsed to bare).
export function computeBasename(
basename: string,
h1: string,
allowDate: boolean,
): string | null {
const prefix = extractPrefix(basename, allowDate);
if (prefix === null) return null;
const rawTail = basename.slice(prefix.length); // generateSlug trims leading separators
const tail = generateSlug(h1) || generateSlug(rawTail);
if (!tail) return basename; // nothing sluggable (e.g. "2026-07-30 !!!") -> leave untouched
return buildFilename(prefix, tail);
}

View File

@@ -1,3 +1,4 @@
{ {
"0.3.0": "1.0.0" "0.6.0": "1.0.0",
"0.6.1": "1.0.0"
} }