diff --git a/README.md b/README.md index 7cfe621..9b90cbe 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,80 @@ -# Obsidian Sample Plugin +# Zettelclean -This is a sample plugin for Obsidian (https://obsidian.md). +Keep a Zettelkasten filename's slug in sync with the note's H1 heading. -This project uses TypeScript to provide type checking and documentation. -The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. +Zettelclean resolves the "three sources of truth for a title" problem (filename vs H1 vs +YAML `title:`) by separating concerns: -This sample plugin demonstrates some of the basic functionality the plugin API can do. +- 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. -- Adds a ribbon icon, which shows a Notice when clicked. -- Adds a command "Open modal (simple)" which opens a Modal. -- Adds a plugin setting tab to the settings page. -- Registers a global click event and outputs a Notice on click. -- Registers a global interval which logs 'setInterval' to the console. +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. -## First time developing plugins? +## How it works -Quick starting guide for new plugin devs: +- 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. +- 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 + 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`. -- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. -- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). -- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. -- Install NodeJS, then run `npm i` in the command line under your repo folder. -- Run `npm run dev` to compile your plugin from `src/main.ts` to `main.js`. -- Make changes to `src/main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. -- Reload Obsidian to load the new version of your plugin. -- Enable plugin in settings window. -- For updates to the Obsidian API run `npm update` in the command line under your repo folder. +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 +trailing dashes are trimmed. -## Releasing new releases +## Creating notes -- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. -- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. -- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases -- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. -- Publish the release. +Use Obsidian's built-in **Unique note creator** core plugin to mint the TIMEID: -> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. -> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` +1. Settings -> Core plugins -> enable "Unique note creator". +2. Set "Unique note format" to `YYYYMMDDHHmmss` (14 digits, second precision - avoids + same-minute collisions). +3. Click the "Create new unique note" ribbon icon. You get `20260728145404.md`; type your + `# Title`, and zettelclean takes over from there. -## Adding your plugin to the community plugin list +Zettelclean also accepts an existing 12-digit (`YYYYMMDDHHmm`) vault if you prefer not to +change the setting. -- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). -- Publish an initial version. -- Make sure you have a `README.md` file in the root of your repo. -- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. +### Retrofitting existing notes -## How to use +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, +prepends it, and the note is now a zettel that syncs on future H1 edits. -- Clone this repo. -- Make sure your NodeJS is at least v18 (`node --version`). -- `npm i` to install dependencies. -- `npm run dev` to start compilation in watch mode. +## Pretty sidebar (optional) -## Manually installing the plugin +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: -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. - -## Improve code quality with eslint - -- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. -- This project already has eslint preconfigured, you can invoke a check by running`npm run lint` -- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines. -- A GitHub action is preconfigured to automatically lint every commit on all branches. - -## Funding URL - -You can include funding URLs where people who use your plugin can financially support it. - -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: - -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} +``` +#heading | _basename ``` -If you have multiple URLs, you can also do: +That reads the first H1 directly and falls back to the filename when there is none. -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} -``` +## Settings -## API Documentation +None. Zettelclean is zero-config in this version. The 5 second settle and 10 second cooldown +are fixed. -See https://docs.obsidian.md +## Development + +- `npm install` (or `pnpm install`). +- `npm run dev` - compile in watch mode. +- `npm run build` - typecheck and produce `main.js`. +- `npm test` - run the slug unit tests on Node's built-in test runner. +- `npm 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`. + +## License + +0-BSD. diff --git a/VERSION b/VERSION index 9ade015..3443f7b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -zettelclean v0.1 +zettelclean v0.2 diff --git a/eslint.config.mts b/eslint.config.mts index 396cba1..11e1543 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -13,6 +13,7 @@ export default defineConfig( 'package.json', 'package-lock.json', 'tsconfig.json', + 'src/**/*.test.ts', ]), { languageOptions: { diff --git a/manifest.json b/manifest.json index 7eb5987..a2b3c98 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,10 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", - "version": "1.0.0", + "id": "zettelclean", + "name": "Zettelclean", + "version": "0.2.0", "minAppVersion": "1.0.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", - "author": "Obsidian", - "authorUrl": "https://obsidian.md", - "fundingUrl": "https://obsidian.md/pricing", + "description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.", + "author": "Ruben Carlo Benante", + "authorUrl": "https://code.beco.cc/beco/zettelclean", "isDesktopOnly": false } diff --git a/package.json b/package.json index 39a8b52..b88657e 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { - "name": "obsidian-sample-plugin", - "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "name": "zettelclean", + "version": "0.2.0", + "description": "Keep a Zettelkasten filename's slug in sync with the note's H1 heading.", "main": "main.js", "type": "module", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "test": "node --test \"src/**/*.test.ts\"", + "test:watch": "node --test --watch \"src/**/*.test.ts\"", "version": "node version-bump.mjs && git add manifest.json versions.json", "lint": "eslint ." }, diff --git a/src/main.ts b/src/main.ts index db213e8..cd9d6bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,114 +1,110 @@ -import { - Editor, - MarkdownView, - MarkdownFileInfo, - Modal, - Notice, - Plugin, -} from 'obsidian'; -import { - DEFAULT_SETTINGS, - MyPluginSettings, - SampleSettingTab, -} from './settings'; +import { Plugin, TFile, TAbstractFile, Menu, moment } from 'obsidian'; +import { extractTimeId, generateSlug, buildFilename } from './slug'; -// Remember to rename these classes and interfaces! +const SETTLE_MS = 5000; // rename ~5s after the H1 stops changing +const COOLDOWN_MS = 10000; // do not rename the same path again within 10s -export default class MyPlugin extends Plugin { - settings!: MyPluginSettings; +export default class ZettelcleanPlugin extends Plugin { + private renameTimers = new Map(); + private lastRenamedAt = new Map(); + private isRenameInProgress = false; async onload() { - await this.loadSettings(); - - // This creates an icon in the left ribbon. - this.addRibbonIcon('dice', 'Sample', (_evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); - }); - - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status bar text'); - - // This adds a simple command that can be triggered anywhere - this.addCommand({ - id: 'open-modal-simple', - name: 'Open modal (simple)', - callback: () => { - new SampleModal(this.app).open(); - }, - }); - // This adds an editor command that can perform some operation on the current editor instance - this.addCommand({ - id: 'replace-selected', - name: 'Replace selected content', - editorCallback: ( - editor: Editor, - _ctx: MarkdownView | MarkdownFileInfo, - ) => { - editor.replaceSelection('Sample editor command'); - }, - }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-modal-complex', - name: 'Open modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = - this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } - - // This command will only show up in Command Palette when the check function returns true - return true; - } - return false; - }, - }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); - - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(activeDocument, 'click', (_evt: MouseEvent) => { - new Notice('Click'); - }); - - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval( - window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000), + // Fires after the metadata cache reparses a note, so headings[] is fresh. + this.registerEvent( + this.app.metadataCache.on('changed', (file) => + this.scheduleSync(file), + ), + ); + // Retrofit entry in the file-explorer right-click menu, near "Rename". + this.registerEvent( + this.app.workspace.on('file-menu', (menu, file) => + this.addTimeIdMenu(menu, file), + ), ); } - onunload() {} + onunload() { + for (const t of this.renameTimers.values()) window.clearTimeout(t); + this.renameTimers.clear(); + } - async loadSettings() { - this.settings = Object.assign( - {}, - DEFAULT_SETTINGS, - (await this.loadData()) as Partial, + private pathFor(file: TFile, base: string): string { + const dir = + file.parent && file.parent.path !== '/' ? `${file.parent.path}/` : ''; + 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. + 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 + 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 + + const existing = this.renameTimers.get(file.path); + if (existing) window.clearTimeout(existing); + this.renameTimers.set( + file.path, + window.setTimeout(() => { + this.renameTimers.delete(file.path); + void this.syncNow(file); + }, SETTLE_MS), ); } - async saveSettings() { - await this.saveData(this.settings); - } -} + // Compute the target basename, bail if unchanged, otherwise rename. + private async syncNow(file: TFile) { + const id = extractTimeId(file.basename); + if (id === null) return; // prefix removed meanwhile -class SampleModal extends Modal { - onOpen() { - const { contentEl } = this; - contentEl.setText('Woah!'); + const first = this.app.metadataCache.getFileCache(file)?.headings?.[0]; + const h1 = first && first.level === 1 ? first.heading : ''; + + const newBase = buildFilename(id, generateSlug(h1)); + if (newBase === file.basename) return; // nothing to do + + const newPath = this.pathFor(file, newBase); + 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); + } finally { + this.isRenameInProgress = false; + } } - onClose() { - const { contentEl } = this; - contentEl.empty(); + private addTimeIdMenu(menu: Menu, file: TAbstractFile) { + if (!(file instanceof TFile) || file.extension !== 'md') return; + if (extractTimeId(file.basename) !== null) return; // already a zettel + menu.addItem((item) => + item + .setTitle('Add zettel TIMEID to filename') + .setIcon('clock') + .onClick(() => void this.addTimeId(file)), + ); + } + + // Retrofit: mint a fresh timestamp for a note that has no TIMEID yet. + private async addTimeId(file: TFile) { + let id = moment().format('YYYYMMDDHHmmss'); // core Unique-note format + const first = this.app.metadataCache.getFileCache(file)?.headings?.[0]; + const h1 = + first && first.level === 1 ? first.heading : file.basename; + const slug = generateSlug(h1); + + let target = this.pathFor(file, buildFilename(id, slug)); + // Rare same-second + same-title collision: extend the id (up to 18 digits). + while (this.app.vault.getAbstractFileByPath(target) && id.length < 18) { + id += String(Math.floor(Date.now() % 10)); + target = this.pathFor(file, buildFilename(id, slug)); + } + await this.app.fileManager.renameFile(file, target); } } diff --git a/tsconfig.json b/tsconfig.json index 3f35db2..fb4b738 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,6 @@ "allowSyntheticDefaultImports": true, "lib": ["ES2021", "DOM"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] } diff --git a/versions.json b/versions.json index 0ff7302..6324d1d 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "1.0.0": "1.0.0" + "0.2.0": "1.0.0" }