README starting the real plugin v0.2

This commit is contained in:
2026-07-28 16:52:19 -03:00
parent a230b3764a
commit 1c4aa2a263
8 changed files with 169 additions and 182 deletions

130
README.md
View File

@@ -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.

View File

@@ -1 +1 @@
zettelclean v0.1
zettelclean v0.2

View File

@@ -13,6 +13,7 @@ export default defineConfig(
'package.json',
'package-lock.json',
'tsconfig.json',
'src/**/*.test.ts',
]),
{
languageOptions: {

View File

@@ -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
}

View File

@@ -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 ."
},

View File

@@ -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<string, number>();
private lastRenamedAt = new Map<string, number>();
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<MyPluginSettings>,
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
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;
}
}
class SampleModal extends Modal {
onOpen() {
const { contentEl } = this;
contentEl.setText('Woah!');
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)),
);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
// 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);
}
}

View File

@@ -15,5 +15,6 @@
"allowSyntheticDefaultImports": true,
"lib": ["ES2021", "DOM"]
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}

View File

@@ -1,3 +1,3 @@
{
"1.0.0": "1.0.0"
"0.2.0": "1.0.0"
}