diff --git a/README.md b/README.md index a1516c8..8763f16 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,18 @@ slug tail follows. Nothing else is touched. ## How it works - A note is a zettel when its filename starts with a **TIMEID**: a run of 12 to 18 digits - (a timestamp), for example `20260728145404-my-note.md`. Files without that prefix are - ignored, so plain notes, daily notes, Excalidraw and Kanban files are never renamed. + (a timestamp) followed by any non-digit or the end of the name - so `20260728145404-my-note.md`, + `202607281454.md`, and even `202607281454 my note.md` all qualify. Files without that prefix + are ignored, so plain notes, daily notes, Excalidraw and Kanban files are never renamed. - The TIMEID is minted **once** and never changes. Only the slug tail is regenerated. -- When you edit the H1, zettelclean waits about 5 seconds of idle, then renames the file to - `TIMEID-slug(H1).md` using Obsidian's own rename (so backlinks are updated). A 10 second +- When you edit the H1, zettelclean waits about 4 seconds of idle, then renames the file to + `TIMEID-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. - 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`. -The slug is lowercase, ASCII, dash-separated: accents are folded (`Ciências` becomes -`ciencias`), runs of punctuation and whitespace collapse to a single dash, and leading and +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 @@ -70,6 +71,13 @@ Notes created before you adopted this workflow have no TIMEID. Right-click such 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. +## 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, @@ -84,7 +92,7 @@ That reads the first H1 directly and falls back to the filename when there is no ## Settings -None. Zettelclean is zero-config in this version. The 5 second settle and 10 second cooldown +None. Zettelclean is zero-config in this version. The 4 second settle and 6 second cooldown are fixed. ## Development diff --git a/VERSION b/VERSION index 5aff5a0..22c692f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -zettelclean v0.4 +zettelclean v0.5 diff --git a/manifest.json b/manifest.json index 40c4f3b..b68adbf 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "zettelclean", "name": "Zettelclean", - "version": "0.4.0", + "version": "0.5.0", "minAppVersion": "1.0.0", "description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.", "author": "Ruben Carlo Benante", diff --git a/package.json b/package.json index 0d0e167..3de19b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zettelclean", - "version": "0.4.0", + "version": "0.5.0", "description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.", "author": "Ruben Carlo Benante ", "main": "main.js", diff --git a/src/main.ts b/src/main.ts index 0bd3259..a826ac2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,11 +19,19 @@ // * rcb@beco.cc * // ************************************************************************* -import { Plugin, TFile, TAbstractFile, Menu, moment } from 'obsidian'; +import { + Plugin, + TFile, + TAbstractFile, + TFolder, + Menu, + Notice, + moment, +} from 'obsidian'; import { extractTimeId, generateSlug, buildFilename } from './slug'; -const SETTLE_MS = 5000; // rename ~5s after the H1 stops changing -const COOLDOWN_MS = 10000; // do not rename the same path again within 10s +const SETTLE_MS = 4000; // rename ~4s after the H1 stops changing +const COOLDOWN_MS = 6000; // do not rename the same path again within 6s export default class ZettelcleanPlugin extends Plugin { private renameTimers = new Map(); @@ -37,11 +45,12 @@ export default class ZettelcleanPlugin extends Plugin { 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.app.workspace.on('file-menu', (menu, file) => - this.addTimeIdMenu(menu, file), - ), + this.app.workspace.on('file-menu', (menu, file) => { + this.addTimeIdMenu(menu, file); + this.addFolderSlugMenu(menu, file); + }), ); } @@ -56,8 +65,8 @@ export default class ZettelcleanPlugin extends Plugin { return `${dir}${base}.md`; } - // Guard layer: only the actively edited zettel, honoring the 10s cooldown, - // (re)arming the 5s settle timer on every cache change. + // Guard layer: only the actively edited zettel, honoring the 6s cooldown, + // (re)arming the 4s settle timer on every cache change. private scheduleSync(file: TAbstractFile) { if (this.isRenameInProgress) return; // our own rename, ignore if (!(file instanceof TFile) || file.extension !== 'md') return; @@ -65,7 +74,7 @@ export default class ZettelcleanPlugin extends Plugin { if (this.app.workspace.getActiveFile() !== file) return; // only the edited note 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); if (existing) window.clearTimeout(existing); @@ -128,4 +137,32 @@ export default class ZettelcleanPlugin extends Plugin { } 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}"`); + } } diff --git a/src/slug.test.ts b/src/slug.test.ts index a7d458d..cb197cb 100644 --- a/src/slug.test.ts +++ b/src/slug.test.ts @@ -23,15 +23,16 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { extractTimeId, generateSlug, buildFilename } from './slug.ts'; -test('slug folds accents, lowercases, collapses', () => { +test('slug folds accents and collapses, preserving case', () => { assert.equal( 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', () => { - 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'); }); @@ -40,11 +41,13 @@ test('empty or heading-less input yields empty slug', () => { assert.equal(generateSlug(' --- '), ''); }); -test('extractTimeId accepts 12/14/18-digit prefixes, rejects others', () => { - assert.equal(extractTimeId('202607281454-x'), '202607281454'); // 12 +test('extractTimeId accepts 12/14/18-digit prefixes and a non-dash boundary', () => { + assert.equal(extractTimeId('202607281454-x'), '202607281454'); // 12, dash assert.equal(extractTimeId('20260728145404-x'), '20260728145404'); // 14 assert.equal(extractTimeId('202607281454049999-x'), '202607281454049999'); // 18 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('2026-budget'), null); // too short assert.equal(extractTimeId('2026072814540499999-x'), null); // 19, too long diff --git a/src/slug.ts b/src/slug.ts index 434c0b2..5312ac7 100644 --- a/src/slug.ts +++ b/src/slug.ts @@ -23,9 +23,10 @@ // in isolation. The transform is always one-way (H1 -> filename), which lets us // 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. -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. export function extractTimeId(basename: string): string | null { @@ -33,13 +34,13 @@ export function extractTimeId(basename: string): string | null { return m?.[1] ?? null; } -// H1 text -> kebab slug. sanifize as inspiration, deliberately simpler. -export function generateSlug(heading: string): string { - return heading +// 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 .replace(/[̀-ͯ]/g, '') // strip the combining marks (c-cedilla -> c) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') // any run of non-alphanumerics -> one dash + .replace(/[^a-zA-Z0-9]+/g, '-') // non-alphanumerics -> one dash (case kept) .replace(/^-+|-+$/g, ''); // trim leading/trailing dashes } diff --git a/versions.json b/versions.json index dbc2cea..a70ceb3 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "0.4.0": "1.0.0" + "0.5.0": "1.0.0" }