review and new module

This commit is contained in:
2026-07-11 14:33:05 +03:00
parent 4cf5e9da4a
commit 6ff146eaaf
5 changed files with 368 additions and 1 deletions
+194
View File
@@ -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 <html lang>, 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<string, {
enabled: boolean;
settings: Record<string, unknown>; // module-specific, typed per module
}>;
}
```
### Localization (`locales/`)
- **Locale type** defined in `en.ts` as the interface `Locale`
- **Language detection:** reads `<html lang>` 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<string, unknown> {
return { myOption: 'default' };
}
onload(plugin: Plugin, moduleSettings: Record<string, unknown>): void {
// register events/commands via plugin.registerEvent / plugin.addCommand
// use this.registerCleanup(fn) for teardown
}
renderInlineSettings(
containerEl: HTMLElement,
settings: Record<string, unknown>,
saveSettings: () => Promise<void>,
): 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
+17
View File
@@ -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',
},
},
};
+9
View File
@@ -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) файлах',
},
},
};
+5 -1
View File
@@ -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(),
];
+143
View File
@@ -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<string, unknown> {
return {
processEmbeds: false,
processCanvas: false,
};
}
onload(plugin: ZeroQoLModulesPlugin, moduleSettings: Record<string, unknown>): 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<string, unknown>,
saveSettings: () => Promise<void>,
): 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<void> {
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}]]`;
});
}
}