review and new module
This commit is contained in:
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) файлах',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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}]]`;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user