From 6ff146eaaf5b1e7afe4e024629ba221e2cb2c85b Mon Sep 17 00:00:00 2001 From: oqyude Date: Sat, 11 Jul 2026 14:33:05 +0300 Subject: [PATCH] review and new module --- review.md | 194 +++++++++++++++++++++ src/locales/en.ts | 17 ++ src/locales/ru.ts | 9 + src/modules/index.ts | 6 +- src/modules/preserve-link-aliases/index.ts | 143 +++++++++++++++ 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 review.md create mode 100644 src/modules/preserve-link-aliases/index.ts diff --git a/review.md b/review.md new file mode 100644 index 0000000..95d85d7 --- /dev/null +++ b/review.md @@ -0,0 +1,194 @@ +# Project Review: zeroq-qol-modules + +## Overview + +Obsidian plugin with a **modular architecture**. Each feature is a separate module that can be toggled on/off in settings. + +**Plugin ID:** `zeroq-qol-modules` +**Entry point:** `src/main.ts` → compiles to `main.js` (esbuild) +**Version source:** `package.json` → auto-synced to `manifest.json` on build + +## Stack + +- **Language:** TypeScript (5.x) +- **Bundler:** esbuild (0.17.x) +- **Obsidian API:** `obsidian` latest (type defs only, excluded from bundle) +- **Format:** CommonJS (`format: 'cjs'`), target ES2018 + +## Directory Structure + +``` +src/ +├── main.ts # Plugin class — loads settings, bootstraps modules +├── settings.ts # ZeroQSettingTab — single settings pane, renders all modules +├── types.ts # QoLModule interface, ZeroQSettings, ModuleSettings +├── locales/ +│ ├── en.ts # Locale type definition + English dictionary +│ ├── ru.ts # Russian dictionary (same Locale shape) +│ └── index.ts # Auto-detects language from , exports locale +└── modules/ + ├── index.ts # Static registry: array of all module instances + ├── base-module.ts # BaseModule abstract class + └── attachment-clean-paste/ + └── index.ts # Concrete module implementation +``` + +## Architecture + +### Plugin Lifecycle (`main.ts`) + +``` +onload() + → loadSettings() # merge DEFAULT_SETTINGS + saved data + → addSettingTab() # register ZeroQSettingTab + → for each module: + if enabled → loadModule(module) + → module.onload(this, moduleSettings) + +onunload() + → for each module: + unloadModule(module) + → module.onunload(this) +``` + +### Module Lifecycle + +Each module is a **singleton** implementing `QoLModule` (or extending `BaseModule`): + +| Method | Purpose | +|--------|---------| +| `onload(plugin, moduleSettings)` | Initialize: register events, commands, setting tab | +| `onunload(plugin)` | Cleanup (auto-runs `registerCleanup` callbacks) | +| `renderInlineSettings(containerEl, settings, saveSettings)` | Render module-specific settings inline (no separate tab) | +| `defaultSettings` | Default module settings object | + +### Cleanup pattern + +Modules use `this.registerCleanup(fn)` to queue cleanup callbacks. They auto-run in `onunload()`. Example: + +```ts +const ref = plugin.app.workspace.on('editor-paste', handler); +this.registerCleanup(() => plugin.app.workspace.offref(ref)); +``` + +### Settings architecture + +- **ZeroQSettingTab** (`settings.ts`) — single Obsidian settings pane +- Iterates `MODULES`, renders each with: + 1. Toggle (enable/disable) — toggling calls `loadModule` / `unloadModule` at runtime + 2. `module.renderInlineSettings()` — module-specific controls rendered directly below the toggle +- **No separate PluginSettingTab per module** — all in one block + +### Settings data shape + +```ts +interface ZeroQSettings { + modules: Record; // module-specific, typed per module + }>; +} +``` + +### Localization (`locales/`) + +- **Locale type** defined in `en.ts` as the interface `Locale` +- **Language detection:** reads `` attribute (set by Obsidian per its own language setting) +- Fallback to `'en'` if language is not in the dictionary +- Usage: `import { locale } from '../../locales'` → `locale.settings.title`, `locale.modules['xxx'].name` + +**To add a language:** +1. Create `src/locales/de.ts` with `Locale` shape +2. Import it in `src/locales/index.ts` and add to `locales` record + +## Adding a New Module + +### Step 1: Create the module class + +Extend `BaseModule` in `src/modules/my-module/index.ts`: + +```ts +import { Plugin } from 'obsidian'; +import { BaseModule } from '../base-module'; +import { locale } from '../../locales'; + +export class MyModule extends BaseModule { + id = 'my-module'; + name = locale.modules['my-module'].name; + description = locale.modules['my-module'].description; + + get defaultSettings(): Record { + return { myOption: 'default' }; + } + + onload(plugin: Plugin, moduleSettings: Record): void { + // register events/commands via plugin.registerEvent / plugin.addCommand + // use this.registerCleanup(fn) for teardown + } + + renderInlineSettings( + containerEl: HTMLElement, + settings: Record, + saveSettings: () => Promise, + ): void { + // render controls into containerEl + // mutate settings object directly, then call saveSettings() + new Setting(containerEl) + .setName('My option') + .addText(text => text + .setValue(String(settings.myOption ?? '')) + .onChange(async v => { + settings.myOption = v; + await saveSettings(); + })); + } +} +``` + +### Step 2: Add translations + +In `src/locales/en.ts`, add to the `Locale` interface and the `en` object: + +```ts +'modules': { + 'my-module': { + name: string; + description: string; + // any other strings this module needs + }; +} +``` + +Do the same in `src/locales/ru.ts`. + +### Step 3: Register in the registry + +In `src/modules/index.ts`: + +```ts +import { MyModule } from './my-module'; + +export const MODULES: QoLModule[] = [ + new AttachmentCleanPasteModule(), + new MyModule(), +]; +``` + +## Build + +```bash +npm run build # production build (minified, no sourcemaps) +npm run dev # dev build (inline sourcemaps) +``` + +`esbuild.config.mjs` also syncs `version` from `package.json` to `manifest.json` before building. + +## Conventions + +- **No comments** in source code +- **No emojis** in code or UI +- **Indentation:** tabs (Obsidian convention) +- **Imports:** `import type` for type-only imports to avoid circular dependencies +- **File naming:** `kebab-case` for files, `PascalCase` for classes +- **Russian labels** for Russian locale, **English labels** for all other locales +- Module `id` is `kebab-case` and matches the key in the locales `modules` object diff --git a/src/locales/en.ts b/src/locales/en.ts index 1ba6c38..fcb88bf 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -12,6 +12,14 @@ export interface Locale { extensionsDesc: string; extensionsPlaceholder: string; }; + 'preserve-link-aliases': { + name: string; + description: string; + processEmbedsLabel: string; + processEmbedsDesc: string; + processCanvasLabel: string; + processCanvasDesc: string; + }; }; } @@ -32,5 +40,14 @@ export const en: Locale = { 'Comma-separated extensions (e.g.: png, jpg, pdf, mp3). Files with these extensions will be inserted as [[path/file|name]] instead of ![[path/file]].', extensionsPlaceholder: 'png, jpg, jpeg, gif, svg, webp, pdf', }, + 'preserve-link-aliases': { + name: 'Preserve Link Aliases', + description: + 'Preserves original link aliases when attachments are renamed', + processEmbedsLabel: 'Process embed links', + processEmbedsDesc: 'Also process ![[embed]] links (disabled by default)', + processCanvasLabel: 'Process Canvas files', + processCanvasDesc: 'Also process links in Canvas (.canvas) files', + }, }, }; diff --git a/src/locales/ru.ts b/src/locales/ru.ts index eb04df3..bbc6a1a 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -17,5 +17,14 @@ export const ru: Locale = { 'Расширения через запятую (например: png, jpg, pdf, mp3). Файлы с этими расширениями будут вставляться как [[path/file|name]] вместо ![[path/file]].', extensionsPlaceholder: 'png, jpg, jpeg, gif, svg, webp, pdf', }, + 'preserve-link-aliases': { + name: 'Сохранение алиасов ссылок', + description: + 'Сохраняет оригинальные алиасы ссылок при переименовании вложений', + processEmbedsLabel: 'Обрабатывать embed-ссылки', + processEmbedsDesc: 'Также обрабатывать ![[embed]] ссылки (по умолчанию выключено)', + processCanvasLabel: 'Обрабатывать Canvas-файлы', + processCanvasDesc: 'Также обрабатывать ссылки в Canvas (.canvas) файлах', + }, }, }; diff --git a/src/modules/index.ts b/src/modules/index.ts index 9bda70c..c159fc3 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,4 +1,8 @@ import { AttachmentCleanPasteModule } from './attachment-clean-paste'; +import { PreserveLinkAliasesModule } from './preserve-link-aliases'; import { QoLModule } from '../types'; -export const MODULES: QoLModule[] = [new AttachmentCleanPasteModule()]; +export const MODULES: QoLModule[] = [ + new AttachmentCleanPasteModule(), + new PreserveLinkAliasesModule(), +]; diff --git a/src/modules/preserve-link-aliases/index.ts b/src/modules/preserve-link-aliases/index.ts new file mode 100644 index 0000000..139d8c3 --- /dev/null +++ b/src/modules/preserve-link-aliases/index.ts @@ -0,0 +1,143 @@ +import { Plugin, TFile, Vault, Setting } from 'obsidian'; +import type ZeroQoLModulesPlugin from '../../main'; +import { BaseModule } from '../base-module'; +import { locale } from '../../locales'; + +const MODULE_ID = 'preserve-link-aliases'; + +interface PreserveLinkAliasesSettings { + processEmbeds: boolean; + processCanvas: boolean; +} + +export class PreserveLinkAliasesModule extends BaseModule { + id = MODULE_ID; + name = locale.modules['preserve-link-aliases'].name; + description = locale.modules['preserve-link-aliases'].description; + + private vault: Vault; + + get defaultSettings(): Record { + return { + processEmbeds: false, + processCanvas: false, + }; + } + + onload(plugin: ZeroQoLModulesPlugin, moduleSettings: Record): void { + this.vault = plugin.app.vault; + const rawSettings = moduleSettings as PreserveLinkAliasesSettings; + + const renameRef = plugin.app.vault.on( + 'rename', + (file: TFile, oldPath: string) => this.handleRename(file, oldPath, rawSettings), + ); + this.registerCleanup(() => plugin.app.vault.offref(renameRef)); + } + + renderInlineSettings( + containerEl: HTMLElement, + settings: Record, + saveSettings: () => Promise, + ): void { + const loc = locale.modules['preserve-link-aliases']; + const s = settings as unknown as PreserveLinkAliasesSettings; + + new Setting(containerEl) + .setName(loc.processEmbedsLabel) + .setDesc(loc.processEmbedsDesc) + .addToggle((toggle) => + toggle + .setValue(s.processEmbeds) + .onChange(async (value) => { + s.processEmbeds = value; + await saveSettings(); + }), + ); + + new Setting(containerEl) + .setName(loc.processCanvasLabel) + .setDesc(loc.processCanvasDesc) + .addToggle((toggle) => + toggle + .setValue(s.processCanvas) + .onChange(async (value) => { + s.processCanvas = value; + await saveSettings(); + }), + ); + } + + private async handleRename( + file: TFile, + oldPath: string, + settings: PreserveLinkAliasesSettings, + ): Promise { + const oldName = oldPath.split('/').pop() ?? ''; + const newName = file.name; + + const oldBasename = oldName.replace(/\.[^/.]+$/, ''); + const newBasename = newName.replace(/\.[^/.]+$/, ''); + + if (oldBasename === newBasename) return; + + const files = this.getFilesToProcess(settings); + + for (const f of files) { + const content = await this.vault.read(f); + const updated = this.processContent(content, newName, newBasename, oldBasename, settings); + + if (updated !== content) { + await this.vault.modify(f, updated); + } + } + } + + private getFilesToProcess(settings: PreserveLinkAliasesSettings): TFile[] { + const files: TFile[] = []; + + for (const file of this.vault.getMarkdownFiles()) { + files.push(file); + } + + if (settings.processCanvas) { + const abstractFiles = this.vault.getFiles(); + for (const file of abstractFiles) { + if (file.extension === 'canvas') { + files.push(file as TFile); + } + } + } + + return files; + } + + private processContent( + content: string, + newFileName: string, + newBasename: string, + oldBasename: string, + settings: PreserveLinkAliasesSettings, + ): string { + const wikiLinkRe = /(!?)\[\[([^\]]*?)]]/g; + + return content.replace(wikiLinkRe, (fullMatch, prefix, inner) => { + if (prefix === '!' && !settings.processEmbeds) { + return fullMatch; + } + + const pipeIndex = inner.indexOf('|'); + if (pipeIndex === -1) return fullMatch; + + const linkPath = inner.slice(0, pipeIndex); + const alias = inner.slice(pipeIndex + 1); + + if (alias !== newBasename) return fullMatch; + + const linkFilename = linkPath.split('/').pop(); + if (linkFilename !== newFileName) return fullMatch; + + return `${prefix}[[${linkPath}|${oldBasename}]]`; + }); + } +}