1 Commits
v0.4 ... v0.5

Author SHA1 Message Date
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
8 changed files with 82 additions and 33 deletions

View File

@@ -38,17 +38,18 @@ 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 **TIMEID**: a run of 12 to 18 digits
(a timestamp), for example `20260728145404-my-note.md`. Files without that prefix are (a timestamp) followed by any non-digit or the end of the name - so `20260728145404-my-note.md`,
ignored, so plain notes, daily notes, Excalidraw and Kanban files are never renamed. `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. - 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 - 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 10 second `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. 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`. - 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 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
@@ -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, 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.
## 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,7 +92,7 @@ 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 None. Zettelclean is zero-config in this version. The 4 second settle and 6 second cooldown
are fixed. are fixed.
## Development ## Development

View File

@@ -1 +1 @@
zettelclean v0.4 zettelclean v0.5

View File

@@ -1,7 +1,7 @@
{ {
"id": "zettelclean", "id": "zettelclean",
"name": "Zettelclean", "name": "Zettelclean",
"version": "0.4.0", "version": "0.5.0",
"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,6 +1,6 @@
{ {
"name": "zettelclean", "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.", "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",

View File

@@ -19,11 +19,19 @@
// * rcb@beco.cc * // * 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'; import { extractTimeId, generateSlug, buildFilename } from './slug';
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 {
private renameTimers = new Map<string, number>(); private renameTimers = new Map<string, number>();
@@ -37,11 +45,12 @@ export default class ZettelcleanPlugin extends Plugin {
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);
}),
); );
} }
@@ -56,8 +65,8 @@ export default class ZettelcleanPlugin extends Plugin {
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;
@@ -65,7 +74,7 @@ export default class ZettelcleanPlugin extends Plugin {
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);
@@ -128,4 +137,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}"`);
}
} }

View File

@@ -23,15 +23,16 @@ 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, 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 +41,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

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,13 @@ export function extractTimeId(basename: string): string | null {
return m?.[1] ?? null; return m?.[1] ?? null;
} }
// H1 text -> kebab slug. sanifize as inspiration, deliberately simpler. // text (an H1 or a folder name) -> ASCII, case-preserving, dash-separated slug.
export function generateSlug(heading: string): string { // Shared by the file-rename sync and the folder-slug menu.
return heading 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
} }

View File

@@ -1,3 +1,3 @@
{ {
"0.4.0": "1.0.0" "0.5.0": "1.0.0"
} }