diff --git a/README.md b/README.md index 8763f16..8b028a0 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,23 @@ 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) 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. +- 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-dd` date** too if you enable date mode in settings. + The prefix is followed by any non-digit or the end of the name - so `20260728145404-my-note.md`, + `202607281454.md`, `202607281454 my note.md`, and (date mode) `2026-07-30 Journal.md` all + 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 - `TIMEID-slug(H1).md` using Obsidian's own rename (so backlinks are updated). A 6 second + `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. - 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 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 @@ -71,6 +78,12 @@ 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. +### 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 @@ -92,8 +105,12 @@ 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 4 second settle and 6 second cooldown -are fixed. +One setting: **Recognized filename prefixes** (Settings -> Community plugins -> Zettelclean). + +- **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 diff --git a/VERSION b/VERSION index 22c692f..65ba116 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -zettelclean v0.5 +zettelclean v0.6 diff --git a/manifest.json b/manifest.json index b68adbf..cc77020 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "zettelclean", "name": "Zettelclean", - "version": "0.5.0", + "version": "0.6.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 3de19b7..cd186ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zettelclean", - "version": "0.5.0", + "version": "0.6.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 a826ac2..f0f4a60 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,17 +28,31 @@ import { Notice, moment, } from 'obsidian'; -import { extractTimeId, generateSlug, buildFilename } from './slug'; +import { + extractPrefix, + computeBasename, + generateSlug, + buildFilename, +} from './slug'; +import { + ZettelcleanSettings, + DEFAULT_SETTINGS, + ZettelcleanSettingTab, +} from './settings'; 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 { + settings!: ZettelcleanSettings; private renameTimers = new Map(); private lastRenamedAt = new Map(); private isRenameInProgress = false; async onload() { + await this.loadSettings(); + this.addSettingTab(new ZettelcleanSettingTab(this.app, this)); + // Fires after the metadata cache reparses a note, so headings[] is fresh. this.registerEvent( this.app.metadataCache.on('changed', (file) => @@ -59,6 +73,18 @@ export default class ZettelcleanPlugin extends Plugin { this.renameTimers.clear(); } + async loadSettings() { + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + (await this.loadData()) as Partial, + ); + } + + async saveSettings() { + await this.saveData(this.settings); + } + private pathFor(file: TFile, base: string): string { const dir = file.parent && file.parent.path !== '/' ? `${file.parent.path}/` : ''; @@ -70,7 +96,8 @@ export default class ZettelcleanPlugin extends Plugin { private scheduleSync(file: TAbstractFile) { if (this.isRenameInProgress) return; // our own rename, ignore 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 const last = this.lastRenamedAt.get(file.path); @@ -87,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) { - const id = extractTimeId(file.basename); - if (id === null) return; // prefix removed meanwhile - const first = this.app.metadataCache.getFileCache(file)?.headings?.[0]; const h1 = first && first.level === 1 ? first.heading : ''; + const allowDate = this.settings.prefixMode === 'date'; - const newBase = buildFilename(id, generateSlug(h1)); - if (newBase === file.basename) return; // nothing to do + const newBase = computeBasename(file.basename, h1, allowDate); + if (newBase === null || newBase === file.basename) return; // unrecognized or no change 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; try { await this.app.fileManager.renameFile(file, newPath); this.lastRenamedAt.set(newPath, Date.now()); } catch (e) { console.error('zettelclean: rename failed', e); + new Notice('Rename failed (see console)'); } finally { this.isRenameInProgress = false; } @@ -112,7 +142,8 @@ export default class ZettelcleanPlugin extends Plugin { private addTimeIdMenu(menu: Menu, file: TAbstractFile) { 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) => item .setTitle('Prefix timestamp to filename') diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..aa4c422 --- /dev/null +++ b/src/settings.ts @@ -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 = { + 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 { + 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(); + }, + ); + }); + } +} diff --git a/src/slug.test.ts b/src/slug.test.ts index cb197cb..82e4322 100644 --- a/src/slug.test.ts +++ b/src/slug.test.ts @@ -21,7 +21,13 @@ import { test } from 'node:test'; 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 and collapses, preserving case', () => { assert.equal( @@ -60,3 +66,31 @@ test('buildFilename collapses empty slug to bare id', () => { ); 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); +}); diff --git a/src/slug.ts b/src/slug.ts index 5312ac7..32ac9d1 100644 --- a/src/slug.ts +++ b/src/slug.ts @@ -34,6 +34,23 @@ export function extractTimeId(basename: string): string | null { return m?.[1] ?? null; } +// A date prefix is yyyy-mm-dd, ending at any non-digit or end-of-name. Fixed digit +// counts with literal dashes, so it captures exactly the date and never breaks at the +// 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 { @@ -48,3 +65,19 @@ export function generateSlug(text: string): string { export function buildFilename(id: string, slug: string): string { 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); +} diff --git a/versions.json b/versions.json index a70ceb3..4f94621 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "0.5.0": "1.0.0" + "0.6.0": "1.0.0" }