9 Commits
Author SHA1 Message Date
oqyude 00cc10b90a v1.1.4 2026-07-11 14:46:30 +03:00
oqyude ec75fd9d72 v1.1.3 2026-07-11 14:46:16 +03:00
oqyude 6ff146eaaf review and new module 2026-07-11 14:33:05 +03:00
oqyude 4cf5e9da4a fix with paragraphs 2026-07-11 03:43:26 +03:00
oqyude d546947e61 some automation
Release / release (push) Has been cancelled
2026-07-11 03:26:31 +03:00
oqyude 801fdec71e workflow and fixes
Release / release (push) Has been cancelled
2026-07-11 03:22:18 +03:00
oqyude e5f1061db7 fix 2026-07-11 03:10:39 +03:00
oqyude 23cf742569 license and fixup 2026-07-11 03:05:42 +03:00
oqyude 2dec5660f6 ru + en lang 2026-07-11 02:54:10 +03:00
20 changed files with 1161 additions and 186 deletions
-63
View File
@@ -1,63 +0,0 @@
name: Draft Release
on:
push:
branches: [master]
jobs:
draft:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: zip -j zeroq-qol-modules.zip main.js manifest.json styles.css
- name: Create or update draft release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -e
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="latest-build"
SHA="${GITHUB_SHA}"
EXISTING=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/releases" \
| jq '.[] | select(.tag_name == "'"${TAG}"'" and .draft == true) | .id')
if [ -z "$EXISTING" ]; then
echo "Creating new draft release..."
RESP=$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'"${TAG}"'",
"name": "Latest Build from master",
"body": "Автоматическая сборка из ветки `master`.\n\nКоммит: '"${SHA}"'",
"draft": true,
"prerelease": true
}' "${API}/releases")
RELEASE_ID=$(echo "$RESP" | jq -r '.id')
else
echo "Updating existing draft release (id: ${EXISTING})..."
RELEASE_ID=$EXISTING
curl -s -X PATCH -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"body": "Автоматическая сборка из ветки `master`.\n\nКоммит: '"${SHA}"'"
}' "${API}/releases/${RELEASE_ID}"
ASSETS=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/releases/${RELEASE_ID}/assets" | jq -r '.[].id')
for AID in $ASSETS; do
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" \
"${API}/releases/${RELEASE_ID}/assets/${AID}"
done
fi
echo "Uploading artifact..."
curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@zeroq-qol-modules.zip" \
"${API}/releases/${RELEASE_ID}/assets"
echo "Done."
+42
View File
@@ -0,0 +1,42 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Verify version
run: |
TAG_VERSION="${GITHUB_REF_NAME#v}"
MANIFEST_VERSION=$(node -p "require('./manifest.json').version")
if [ "$TAG_VERSION" != "$MANIFEST_VERSION" ]; then
echo "❌ Tag ($TAG_VERSION) != manifest ($MANIFEST_VERSION)"
exit 1
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
files: |
main.js
manifest.json
styles.css
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+57 -32
View File
@@ -2,35 +2,71 @@
Модульный плагин для **Obsidian** с набором QoL-функций, которые можно включать и отключать в настройках.
## Установка
Modular **Obsidian** plugin with toggleable QoL modules in settings.
## Установка / Installation
1. Скачайте `main.js`, `manifest.json` и `styles.css` из [релизов](https://github.com/your/repo/releases)
2. Поместите их в папку `.obsidian/plugins/zeroq-qol-modules/` вашего хранилища
3. В Obsidian: **Настройки****Community plugins****Включить**
4. Активируйте плагин **ZeroQ QoL Modules**
## Модули
## Язык / Language
| Модуль | Описание |
|--------|----------|
| **Attachment Clean Paste** | Заменяет `![[вложение]]` на `[[вложение|имя]]` при вставке/перетаскивании файлов с указанными расширениями |
Язык плагина синхронизируется с **языком интерфейса Obsidian** (Settings → About → Language).
- Obsidian на русском → плагин на русском
- Obsidian на другом языке → плагин на английском
The plugin language syncs with **Obsidian's UI language** setting.
- Obsidian in English → plugin in English
- Obsidian in any other language → falls back to English
## Модули / Modules
- **Attachment Clean Paste** — Заменяет `![[embed]]` на `[[path/file|name]]` для файлов с указанными расширениями при вставке/перетаскивании
Каждый модуль включается/отключается в настройках плагина.
## Разработка
## Разработка / Development
### Добавление нового модуля
### Архитектура / Architecture
```
src/
├── main.ts # точка входа, Plugin class
├── settings.ts # общая панель настроек
├── types.ts # базовые типы
├── locales/
│ ├── en.ts # тип Locale + EN-словарь
│ ├── ru.ts # RU-словарь
│ └── index.ts # авто-определение языка
└── modules/
├── index.ts # реестр модулей (статический импорт)
├── base-module.ts # абстрактный класс модуля
└── attachment-clean-paste/
└── index.ts # реализация модуля
```
**Принцип работы:**
- `main.ts` загружает настройки, итерирует `MODULES` и вызывает `onload()` для включённых
- Каждый модуль через `registerCleanup()` регистрирует функции очистки, которые вызываются при выключении модуля или выгрузке плагина
- В настройках отображаются все модули с чекбоксами вкл/выкл
### Добавление нового модуля / Adding a module
1. Создайте папку `src/modules/my-module/`
2. Создайте класс, унаследованный от `BaseModule`:
```typescript
import { BaseModule } from '../base-module';
import { locale } from '../../locales';
export class MyModule extends BaseModule {
id = 'my-module';
name = 'My Module';
description = 'Описание модуля';
name = locale.modules['my-module'].name;
description = locale.modules['my-module'].description;
get defaultSettings() {
return { /* настройки по умолчанию */ };
@@ -46,7 +82,8 @@ export class MyModule extends BaseModule {
}
```
3. Зарегистрируйте модуль в `src/modules/index.ts`:
3. Добавьте переводы в `src/locales/en.ts` и `src/locales/ru.ts`
4. Зарегистрируйте модуль в `src/modules/index.ts`:
```typescript
import { MyModule } from './my-module';
@@ -57,34 +94,22 @@ export const MODULES: QoLModule[] = [
];
```
4. Если модулю нужна своя вкладка настроек — создайте `PluginSettingTab` и добавьте её через `plugin.addSettingTab()` в `onload`
### Добавление нового языка / Adding a language
### Сборка
1. Создайте `src/locales/de.ts` со структурой `Locale`
2. Импортируйте и добавьте в словарь в `src/locales/index.ts`:
```typescript
import { de } from './de';
const locales: Record<string, Locale> = { en, ru, de };
```
### Сборка / Build
```bash
npm run build # production-сборка
npm run dev # dev-сборка с sourcemap
```
### Архитектура
```
src/
├── main.ts # точка входа, Plugin class
├── settings.ts # общая панель настроек
├── types.ts # базовые типы
└── modules/
├── index.ts # реестр модулей (статический импорт)
├── base-module.ts # абстрактный класс модуля
└── attachment-clean-paste/
└── index.ts # реализация модуля
```
**Принцип работы:**
- `main.ts` загружает настройки, итерирует `MODULES` и вызывает `onload()` для включённых
- Каждый модуль через `registerCleanup()` регистрирует функции отчистки, которые вызываются при выключении модуля или выгрузке плагина
- В настройках отображаются все модули с чекбоксами вкл/выкл
## Лицензия
## Лицензия / License
MIT
+6
View File
@@ -1,5 +1,6 @@
import esbuild from 'esbuild';
import process from 'process';
import fs from 'fs';
import builtins from 'builtin-modules';
const banner = `/*
@@ -9,6 +10,11 @@ THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
const prod = process.argv[2] === 'production';
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const manifest = JSON.parse(fs.readFileSync('./manifest.json', 'utf8'));
manifest.version = pkg.version;
fs.writeFileSync('./manifest.json', JSON.stringify(manifest, null, '\t') + '\n');
esbuild
.build({
banner: { js: banner },
+453
View File
@@ -0,0 +1,453 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
*/
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => ZeroQoLModulesPlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian4 = require("obsidian");
// src/modules/attachment-clean-paste/index.ts
var import_obsidian = require("obsidian");
// src/modules/base-module.ts
var BaseModule = class {
constructor() {
this.cleanupFns = [];
}
get defaultSettings() {
return {};
}
onunload(_plugin) {
for (const fn of this.cleanupFns) {
fn();
}
this.cleanupFns = [];
}
renderInlineSettings(_containerEl, _settings, _saveSettings) {
}
registerCleanup(fn) {
this.cleanupFns.push(fn);
}
};
// src/locales/en.ts
var en = {
settings: {
title: "ZeroQ QoL Modules",
description: "Enable or disable modules as you wish."
},
modules: {
"attachment-clean-paste": {
name: "Attachment Clean Paste",
description: "Replaces ![[embed]] with [[path/file|name]] for configured file extensions on paste/drop",
descriptionShort: "Replaces ![[embed]] with [[path/file|name]] for specified file extensions.",
extensionsLabel: "File extensions",
extensionsDesc: "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"
}
}
};
// src/locales/ru.ts
var ru = {
settings: {
title: "ZeroQ QoL Modules",
description: "\u0412\u043A\u043B\u044E\u0447\u0430\u0439\u0442\u0435 \u0438 \u043E\u0442\u043A\u043B\u044E\u0447\u0430\u0439\u0442\u0435 \u043C\u043E\u0434\u0443\u043B\u0438 \u043F\u043E \u0441\u0432\u043E\u0435\u043C\u0443 \u0443\u0441\u043C\u043E\u0442\u0440\u0435\u043D\u0438\u044E."
},
modules: {
"attachment-clean-paste": {
name: "Attachment Clean Paste",
description: "\u0417\u0430\u043C\u0435\u043D\u044F\u0435\u0442 ![[\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435]] \u043D\u0430 [[\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435|\u0438\u043C\u044F]] \u0434\u043B\u044F \u0443\u043A\u0430\u0437\u0430\u043D\u043D\u044B\u0445 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u0439 \u043F\u0440\u0438 \u0432\u0441\u0442\u0430\u0432\u043A\u0435/\u043F\u0435\u0440\u0435\u0442\u0430\u0441\u043A\u0438\u0432\u0430\u043D\u0438\u0438",
descriptionShort: "\u0417\u0430\u043C\u0435\u043D\u044F\u0435\u0442 ![[\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435]] \u043D\u0430 [[\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435|\u0438\u043C\u044F]] \u0434\u043B\u044F \u0443\u043A\u0430\u0437\u0430\u043D\u043D\u044B\u0445 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u0439.",
extensionsLabel: "\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u044F \u0444\u0430\u0439\u043B\u043E\u0432",
extensionsDesc: "\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u044F \u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043F\u044F\u0442\u0443\u044E (\u043D\u0430\u043F\u0440\u0438\u043C\u0435\u0440: png, jpg, pdf, mp3). \u0424\u0430\u0439\u043B\u044B \u0441 \u044D\u0442\u0438\u043C\u0438 \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043D\u0438\u044F\u043C\u0438 \u0431\u0443\u0434\u0443\u0442 \u0432\u0441\u0442\u0430\u0432\u043B\u044F\u0442\u044C\u0441\u044F \u043A\u0430\u043A [[path/file|name]] \u0432\u043C\u0435\u0441\u0442\u043E ![[path/file]].",
extensionsPlaceholder: "png, jpg, jpeg, gif, svg, webp, pdf"
},
"preserve-link-aliases": {
name: "\u0421\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u0438\u0435 \u0430\u043B\u0438\u0430\u0441\u043E\u0432 \u0441\u0441\u044B\u043B\u043E\u043A",
description: "\u0421\u043E\u0445\u0440\u0430\u043D\u044F\u0435\u0442 \u043E\u0440\u0438\u0433\u0438\u043D\u0430\u043B\u044C\u043D\u044B\u0435 \u0430\u043B\u0438\u0430\u0441\u044B \u0441\u0441\u044B\u043B\u043E\u043A \u043F\u0440\u0438 \u043F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u043D\u0438\u0438 \u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0439",
processEmbedsLabel: "\u041E\u0431\u0440\u0430\u0431\u0430\u0442\u044B\u0432\u0430\u0442\u044C embed-\u0441\u0441\u044B\u043B\u043A\u0438",
processEmbedsDesc: "\u0422\u0430\u043A\u0436\u0435 \u043E\u0431\u0440\u0430\u0431\u0430\u0442\u044B\u0432\u0430\u0442\u044C ![[embed]] \u0441\u0441\u044B\u043B\u043A\u0438 (\u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E \u0432\u044B\u043A\u043B\u044E\u0447\u0435\u043D\u043E)",
processCanvasLabel: "\u041E\u0431\u0440\u0430\u0431\u0430\u0442\u044B\u0432\u0430\u0442\u044C Canvas-\u0444\u0430\u0439\u043B\u044B",
processCanvasDesc: "\u0422\u0430\u043A\u0436\u0435 \u043E\u0431\u0440\u0430\u0431\u0430\u0442\u044B\u0432\u0430\u0442\u044C \u0441\u0441\u044B\u043B\u043A\u0438 \u0432 Canvas (.canvas) \u0444\u0430\u0439\u043B\u0430\u0445"
}
}
};
// src/locales/index.ts
var locales = { en, ru };
function detectLocale() {
const lang = (document.documentElement.lang || "en").slice(0, 2);
return locales[lang] || en;
}
var locale = detectLocale();
// src/modules/attachment-clean-paste/index.ts
var MODULE_ID = "attachment-clean-paste";
var AttachmentCleanPasteModule = class extends BaseModule {
constructor() {
super(...arguments);
this.id = MODULE_ID;
this.name = locale.modules["attachment-clean-paste"].name;
this.description = locale.modules["attachment-clean-paste"].description;
}
get defaultSettings() {
return {
extensions: "png, jpg, jpeg, gif, svg, webp, bmp, pdf"
};
}
onload(plugin, moduleSettings) {
this.app = plugin.app;
const rawSettings = moduleSettings;
const pasteRef = plugin.app.workspace.on(
"editor-paste",
this.createPasteHandler(rawSettings)
);
this.registerCleanup(() => plugin.app.workspace.offref(pasteRef));
const dropRef = plugin.app.workspace.on(
"editor-drop",
this.createDropHandler(rawSettings)
);
this.registerCleanup(() => plugin.app.workspace.offref(dropRef));
}
renderInlineSettings(containerEl, settings, saveSettings) {
const loc = locale.modules["attachment-clean-paste"];
const extSettings = settings;
new import_obsidian.Setting(containerEl).setName(loc.extensionsLabel).setDesc(loc.extensionsDesc).addTextArea((textarea) => {
var _a;
textarea.setPlaceholder(loc.extensionsPlaceholder).setValue((_a = extSettings == null ? void 0 : extSettings.extensions) != null ? _a : "").onChange(async (value) => {
settings.extensions = value;
await saveSettings();
});
textarea.inputEl.rows = 4;
textarea.inputEl.style.width = "100%";
});
}
async processAll(files, editor, settings) {
const matchingFiles = Array.from(files).filter(
(f) => this.shouldProcess(f.name, settings)
);
if (matchingFiles.length === 0)
return;
const links = [];
for (const file of matchingFiles) {
const link = await this.processFile(file, settings);
if (link)
links.push(link);
}
if (links.length === 0)
return;
editor.replaceSelection(links.join("\n\n"));
}
createPasteHandler(settings) {
return async (evt, editor, _info) => {
var _a;
const files = (_a = evt.clipboardData) == null ? void 0 : _a.files;
if (!files || files.length === 0)
return;
evt.preventDefault();
evt.stopPropagation();
await this.processAll(files, editor, settings);
};
}
createDropHandler(settings) {
return async (evt, editor, _info) => {
var _a;
const files = (_a = evt.dataTransfer) == null ? void 0 : _a.files;
if (!files || files.length === 0)
return;
evt.preventDefault();
evt.stopPropagation();
await this.processAll(files, editor, settings);
};
}
shouldProcess(filename, settings) {
const dotIndex = filename.lastIndexOf(".");
if (dotIndex === -1)
return false;
const ext = filename.slice(dotIndex + 1).toLowerCase();
const exts = settings.extensions.split(",").map((e) => e.trim().toLowerCase()).filter((e) => e.length > 0);
return exts.some((e) => {
const cleanExt = e.startsWith(".") ? e.slice(1) : e;
return cleanExt === ext;
});
}
async processFile(file, _settings) {
try {
const arrayBuffer = await file.arrayBuffer();
const filename = file.name;
const nameWithoutExt = filename.replace(/\.[^/.]+$/, "");
let attachmentFolder = this.app.vault.getConfig(
"attachmentFolderPath"
);
if (!attachmentFolder)
attachmentFolder = "attachments";
const folderPath = (0, import_obsidian.normalizePath)(attachmentFolder);
if (!await this.app.vault.adapter.exists(folderPath)) {
await this.app.vault.createFolder(folderPath);
}
const ext = filename.slice(filename.lastIndexOf(".") + 1);
const basePath = (0, import_obsidian.normalizePath)(`${folderPath}/${nameWithoutExt}`);
const availablePath = await this.app.vault.getAvailablePath(
basePath,
ext
);
await this.app.vault.createBinary(availablePath, arrayBuffer);
return `[[${availablePath}|${nameWithoutExt}]]`;
} catch (e) {
new import_obsidian.Notice(`Clean Paste error: ${e.message}`);
console.error("Attachment Clean Paste:", e);
}
return null;
}
};
// src/modules/preserve-link-aliases/index.ts
var import_obsidian2 = require("obsidian");
var MODULE_ID2 = "preserve-link-aliases";
var PreserveLinkAliasesModule = class extends BaseModule {
constructor() {
super(...arguments);
this.id = MODULE_ID2;
this.name = locale.modules["preserve-link-aliases"].name;
this.description = locale.modules["preserve-link-aliases"].description;
}
get defaultSettings() {
return {
processEmbeds: false,
processCanvas: false
};
}
onload(plugin, moduleSettings) {
this.vault = plugin.app.vault;
const rawSettings = moduleSettings;
const renameRef = plugin.app.vault.on(
"rename",
(file, oldPath) => this.handleRename(file, oldPath, rawSettings)
);
this.registerCleanup(() => plugin.app.vault.offref(renameRef));
}
renderInlineSettings(containerEl, settings, saveSettings) {
const loc = locale.modules["preserve-link-aliases"];
const s = settings;
new import_obsidian2.Setting(containerEl).setName(loc.processEmbedsLabel).setDesc(loc.processEmbedsDesc).addToggle(
(toggle) => toggle.setValue(s.processEmbeds).onChange(async (value) => {
s.processEmbeds = value;
await saveSettings();
})
);
new import_obsidian2.Setting(containerEl).setName(loc.processCanvasLabel).setDesc(loc.processCanvasDesc).addToggle(
(toggle) => toggle.setValue(s.processCanvas).onChange(async (value) => {
s.processCanvas = value;
await saveSettings();
})
);
}
async handleRename(file, oldPath, settings) {
var _a;
const oldName = (_a = oldPath.split("/").pop()) != null ? _a : "";
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);
}
}
}
getFilesToProcess(settings) {
const files = [];
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);
}
}
}
return files;
}
processContent(content, newFileName, newBasename, oldBasename, settings) {
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}]]`;
});
}
};
// src/modules/index.ts
var MODULES = [
new AttachmentCleanPasteModule(),
new PreserveLinkAliasesModule()
];
// src/settings.ts
var import_obsidian3 = require("obsidian");
var MODULE_DEFAULTS = MODULES.reduce(
(acc, mod) => {
acc[mod.id] = {
enabled: true,
settings: { ...mod.defaultSettings }
};
return acc;
},
{}
);
var DEFAULT_SETTINGS = {
modules: MODULE_DEFAULTS
};
function getModuleSettings(settings, moduleId) {
var _a, _b;
return (_b = (_a = settings.modules[moduleId]) == null ? void 0 : _a.settings) != null ? _b : {};
}
var ZeroQSettingTab = class extends import_obsidian3.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h1", { text: locale.settings.title });
containerEl.createEl("p", {
text: locale.settings.description,
attr: { style: "color: var(--text-muted); margin-bottom: 20px;" }
});
for (const module2 of MODULES) {
const moduleCfg = this.plugin.settings.modules[module2.id];
new import_obsidian3.Setting(containerEl).setName(module2.name).setDesc(module2.description).addToggle(
(toggle) => {
var _a;
return toggle.setValue((_a = moduleCfg == null ? void 0 : moduleCfg.enabled) != null ? _a : true).onChange(async (value) => {
if (!this.plugin.settings.modules[module2.id]) {
this.plugin.settings.modules[module2.id] = {
enabled: value,
settings: { ...module2.defaultSettings }
};
} else {
this.plugin.settings.modules[module2.id].enabled = value;
}
await this.plugin.saveSettings();
if (value) {
this.plugin.loadModule(module2);
} else {
this.plugin.unloadModule(module2);
}
});
}
);
if (moduleCfg) {
module2.renderInlineSettings(
containerEl,
moduleCfg.settings,
() => this.plugin.saveSettings()
);
}
}
}
};
// src/main.ts
var ZeroQoLModulesPlugin = class extends import_obsidian4.Plugin {
constructor() {
super(...arguments);
this.loadedModules = /* @__PURE__ */ new Set();
}
async onload() {
await this.loadSettings();
this.addSettingTab(new ZeroQSettingTab(this.app, this));
for (const module2 of MODULES) {
const moduleCfg = this.settings.modules[module2.id];
if (moduleCfg == null ? void 0 : moduleCfg.enabled) {
this.loadModule(module2);
}
}
}
onunload() {
for (const module2 of MODULES) {
this.unloadModule(module2);
}
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
for (const module2 of MODULES) {
if (!this.settings.modules[module2.id]) {
this.settings.modules[module2.id] = {
enabled: true,
settings: { ...module2.defaultSettings }
};
}
}
}
async saveSettings() {
await this.saveData(this.settings);
}
loadModule(module2) {
if (this.loadedModules.has(module2.id))
return;
const moduleSettings = getModuleSettings(this.settings, module2.id);
module2.onload(this, moduleSettings);
this.loadedModules.add(module2.id);
}
unloadModule(module2) {
if (!this.loadedModules.has(module2.id))
return;
module2.onunload(this);
this.loadedModules.delete(module2.id);
}
};
+3 -3
View File
@@ -1,9 +1,9 @@
{
"id": "zeroq-qol-modules",
"name": "ZeroQ QoL Modules",
"version": "1.0.0",
"version": "1.1.4",
"minAppVersion": "0.15.0",
"description": "Набор QoL-модулей для Obsidian: clean paste, авто-линковка и другое",
"author": "Custom",
"description": "QoL Modules",
"author": "oqyude",
"isDesktopOnly": false
}
+7 -3
View File
@@ -1,13 +1,17 @@
{
"name": "zeroq-qol-modules",
"version": "1.0.0",
"version": "1.1.4",
"description": "Modular Obsidian plugin with toggleable QoL modules",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
"build": "node esbuild.config.mjs production",
"release": "bash scripts/release.sh"
},
"keywords": ["obsidian", "plugin"],
"keywords": [
"obsidian",
"plugin"
],
"author": "",
"license": "MIT",
"devDependencies": {
+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
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
BUMP="${1:-patch}"
CURRENT_VERSION=$(node -p "require('./manifest.json').version")
if [[ "$BUMP" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
NEW_VERSION="$BUMP"
elif [[ "$BUMP" == "patch" ]]; then
NEW_VERSION=$(node -p "
const [a,b,c] = '$CURRENT_VERSION'.split('.').map(Number);
\`\${a}.\${b}.\${c+1}\`
")
elif [[ "$BUMP" == "minor" ]]; then
NEW_VERSION=$(node -p "
const [a,b] = '$CURRENT_VERSION'.split('.').map(Number);
\`\${a}.\${b+1}.0\`
")
elif [[ "$BUMP" == "major" ]]; then
NEW_VERSION=$(node -p "
const [a] = '$CURRENT_VERSION'.split('.').map(Number);
\`\${a+1}.0.0\`
")
else
echo "Usage: $0 [patch|minor|major|<semver>]"
exit 1
fi
echo "Current: $CURRENT_VERSION"
echo "New: $NEW_VERSION"
read -p "Proceed? [y/N] " CONFIRM
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
echo "Aborted."; exit 1
fi
node -e "
const fs = require('fs');
const m = require('./manifest.json'); m.version = '$NEW_VERSION';
fs.writeFileSync('./manifest.json', JSON.stringify(m, null, '\t') + '\n');
const p = require('./package.json'); p.version = '$NEW_VERSION';
fs.writeFileSync('./package.json', JSON.stringify(p, null, '\t') + '\n');
"
npm run build
git add -f manifest.json package.json main.js styles.css
git commit -m "v$NEW_VERSION"
git tag "v$NEW_VERSION"
echo ""
echo "✔ Release v$NEW_VERSION ready."
echo "Run: git push && git push --tags"
+53
View File
@@ -0,0 +1,53 @@
export interface Locale {
settings: {
title: string;
description: string;
};
modules: {
'attachment-clean-paste': {
name: string;
description: string;
descriptionShort: string;
extensionsLabel: string;
extensionsDesc: string;
extensionsPlaceholder: string;
};
'preserve-link-aliases': {
name: string;
description: string;
processEmbedsLabel: string;
processEmbedsDesc: string;
processCanvasLabel: string;
processCanvasDesc: string;
};
};
}
export const en: Locale = {
settings: {
title: 'ZeroQ QoL Modules',
description: 'Enable or disable modules as you wish.',
},
modules: {
'attachment-clean-paste': {
name: 'Attachment Clean Paste',
description:
'Replaces ![[embed]] with [[path/file|name]] for configured file extensions on paste/drop',
descriptionShort:
'Replaces ![[embed]] with [[path/file|name]] for specified file extensions.',
extensionsLabel: 'File extensions',
extensionsDesc:
'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',
},
},
};
+12
View File
@@ -0,0 +1,12 @@
import { en, Locale } from './en';
import { ru } from './ru';
const locales: Record<string, Locale> = { en, ru };
function detectLocale(): Locale {
const lang = (document.documentElement.lang || 'en').slice(0, 2);
return locales[lang] || en;
}
export const locale = detectLocale();
export type { Locale };
+30
View File
@@ -0,0 +1,30 @@
import { Locale } from './en';
export const ru: Locale = {
settings: {
title: 'ZeroQ QoL Modules',
description: 'Включайте и отключайте модули по своему усмотрению.',
},
modules: {
'attachment-clean-paste': {
name: 'Attachment Clean Paste',
description:
'Заменяет ![[вложение]] на [[вложение|имя]] для указанных расширений при вставке/перетаскивании',
descriptionShort:
'Заменяет ![[вложение]] на [[вложение|имя]] для указанных расширений.',
extensionsLabel: 'Расширения файлов',
extensionsDesc:
'Расширения через запятую (например: 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) файлах',
},
},
};
+55 -78
View File
@@ -4,12 +4,12 @@ import {
MarkdownFileInfo,
MarkdownView,
Notice,
PluginSettingTab,
Setting,
normalizePath,
} from 'obsidian';
import type ZeroQoLModulesPlugin from '../../main';
import { BaseModule } from '../base-module';
import { locale } from '../../locales';
const MODULE_ID = 'attachment-clean-paste';
@@ -19,9 +19,8 @@ interface AttachmentCleanPasteSettings {
export class AttachmentCleanPasteModule extends BaseModule {
id = MODULE_ID;
name = 'Attachment Clean Paste';
description =
'Заменяет ![[вложение]] на [[вложение|имя]] для указанных расширений при вставке/перетаскивании';
name = locale.modules['attachment-clean-paste'].name;
description = locale.modules['attachment-clean-paste'].description;
private app: App;
@@ -33,25 +32,65 @@ export class AttachmentCleanPasteModule extends BaseModule {
onload(plugin: ZeroQoLModulesPlugin, moduleSettings: Record<string, unknown>): void {
this.app = plugin.app;
const settings = moduleSettings as unknown as AttachmentCleanPasteSettings;
plugin.addSettingTab(
new AttachmentCleanPasteSettingTab(plugin.app, plugin, MODULE_ID),
);
const rawSettings = moduleSettings as AttachmentCleanPasteSettings;
const pasteRef = plugin.app.workspace.on(
'editor-paste',
this.createPasteHandler(settings),
this.createPasteHandler(rawSettings),
);
this.registerCleanup(() => plugin.app.workspace.offref(pasteRef));
const dropRef = plugin.app.workspace.on(
'editor-drop',
this.createDropHandler(settings),
this.createDropHandler(rawSettings),
);
this.registerCleanup(() => plugin.app.workspace.offref(dropRef));
}
renderInlineSettings(
containerEl: HTMLElement,
settings: Record<string, unknown>,
saveSettings: () => Promise<void>,
): void {
const loc = locale.modules['attachment-clean-paste'];
const extSettings = settings as unknown as AttachmentCleanPasteSettings;
new Setting(containerEl)
.setName(loc.extensionsLabel)
.setDesc(loc.extensionsDesc)
.addTextArea((textarea) => {
textarea
.setPlaceholder(loc.extensionsPlaceholder)
.setValue(extSettings?.extensions ?? '')
.onChange(async (value) => {
settings.extensions = value;
await saveSettings();
});
textarea.inputEl.rows = 4;
textarea.inputEl.style.width = '100%';
});
}
private async processAll(
files: FileList,
editor: Editor,
settings: AttachmentCleanPasteSettings,
): Promise<void> {
const matchingFiles = Array.from(files).filter((f) =>
this.shouldProcess(f.name, settings),
);
if (matchingFiles.length === 0) return;
const links: string[] = [];
for (const file of matchingFiles) {
const link = await this.processFile(file, settings);
if (link) links.push(link);
}
if (links.length === 0) return;
editor.replaceSelection(links.join('\n\n'));
}
private createPasteHandler(settings: AttachmentCleanPasteSettings) {
return async (
evt: ClipboardEvent,
@@ -61,17 +100,10 @@ export class AttachmentCleanPasteModule extends BaseModule {
const files = evt.clipboardData?.files;
if (!files || files.length === 0) return;
const matchingFiles = Array.from(files).filter((f) =>
this.shouldProcess(f.name, settings),
);
if (matchingFiles.length === 0) return;
evt.preventDefault();
evt.stopPropagation();
for (const file of matchingFiles) {
await this.processFile(file, editor, settings);
}
await this.processAll(files, editor, settings);
};
}
@@ -84,17 +116,10 @@ export class AttachmentCleanPasteModule extends BaseModule {
const files = evt.dataTransfer?.files;
if (!files || files.length === 0) return;
const matchingFiles = Array.from(files).filter((f) =>
this.shouldProcess(f.name, settings),
);
if (matchingFiles.length === 0) return;
evt.preventDefault();
evt.stopPropagation();
for (const file of matchingFiles) {
await this.processFile(file, editor, settings);
}
await this.processAll(files, editor, settings);
};
}
@@ -119,9 +144,8 @@ export class AttachmentCleanPasteModule extends BaseModule {
private async processFile(
file: File,
editor: Editor,
_settings: AttachmentCleanPasteSettings,
): Promise<void> {
): Promise<string | null> {
try {
const arrayBuffer = await file.arrayBuffer();
const filename = file.name;
@@ -147,58 +171,11 @@ export class AttachmentCleanPasteModule extends BaseModule {
await this.app.vault.createBinary(availablePath, arrayBuffer);
const link = `[[${availablePath}|${nameWithoutExt}]]`;
editor.replaceSelection(link);
return `[[${availablePath}|${nameWithoutExt}]]`;
} catch (e) {
new Notice(`Clean Paste error: ${e.message}`);
console.error('Attachment Clean Paste:', e);
}
}
}
class AttachmentCleanPasteSettingTab extends PluginSettingTab {
private plugin: ZeroQoLModulesPlugin;
private moduleId: string;
constructor(app: App, plugin: ZeroQoLModulesPlugin, moduleId: string) {
super(app, plugin);
this.plugin = plugin;
this.moduleId = moduleId;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Attachment Clean Paste' });
containerEl.createEl('p', {
text: 'Заменяет ![[вложение]] на [[вложение|имя]] для указанных расширений.',
attr: { style: 'color: var(--text-muted); margin-bottom: 20px;' },
});
const rawSettings = this.plugin.settings.modules[this.moduleId]?.settings as
| AttachmentCleanPasteSettings
| undefined;
new Setting(containerEl)
.setName('Расширения файлов')
.setDesc(
'Расширения через запятую (например: png, jpg, pdf, mp3). Файлы с этими расширениями будут вставляться как [[path/file|name]] вместо ![[path/file]].',
)
.addTextArea((textarea) => {
textarea
.setPlaceholder('png, jpg, jpeg, gif, svg, webp, pdf')
.setValue(rawSettings?.extensions ?? '')
.onChange(async (value) => {
if (this.plugin.settings.modules[this.moduleId]) {
this.plugin.settings.modules[this.moduleId].settings = {
extensions: value,
};
}
await this.plugin.saveSettings();
});
textarea.inputEl.rows = 4;
textarea.inputEl.style.width = '100%';
});
return null;
}
}
+6
View File
@@ -21,6 +21,12 @@ export abstract class BaseModule implements QoLModule {
this.cleanupFns = [];
}
renderInlineSettings(
_containerEl: HTMLElement,
_settings: Record<string, unknown>,
_saveSettings: () => Promise<void>,
): void {}
protected registerCleanup(fn: () => void): void {
this.cleanupFns.push(fn);
}
+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}]]`;
});
}
}
+13 -4
View File
@@ -2,6 +2,7 @@ import { App, PluginSettingTab, Setting } from 'obsidian';
import type ZeroQoLModulesPlugin from './main';
import { MODULES } from './modules';
import { ZeroQSettings } from './types';
import { locale } from './locales';
const MODULE_DEFAULTS = MODULES.reduce(
(acc, mod) => {
@@ -37,21 +38,21 @@ export class ZeroQSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h1', { text: 'ZeroQ QoL Modules' });
containerEl.createEl('h1', { text: locale.settings.title });
containerEl.createEl('p', {
text: 'Включайте и отключайте модули по своему усмотрению.',
text: locale.settings.description,
attr: { style: 'color: var(--text-muted); margin-bottom: 20px;' },
});
for (const module of MODULES) {
const moduleSettings = this.plugin.settings.modules[module.id];
const moduleCfg = this.plugin.settings.modules[module.id];
new Setting(containerEl)
.setName(module.name)
.setDesc(module.description)
.addToggle((toggle) =>
toggle
.setValue(moduleSettings?.enabled ?? true)
.setValue(moduleCfg?.enabled ?? true)
.onChange(async (value) => {
if (!this.plugin.settings.modules[module.id]) {
this.plugin.settings.modules[module.id] = {
@@ -69,6 +70,14 @@ export class ZeroQSettingTab extends PluginSettingTab {
}
}),
);
if (moduleCfg) {
module.renderInlineSettings(
containerEl,
moduleCfg.settings,
() => this.plugin.saveSettings(),
);
}
}
}
}
+5
View File
@@ -16,4 +16,9 @@ export interface QoLModule {
defaultSettings: Record<string, unknown>;
onload(plugin: Plugin, moduleSettings: Record<string, unknown>): void;
onunload(plugin: Plugin): void;
renderInlineSettings(
containerEl: HTMLElement,
settings: Record<string, unknown>,
saveSettings: () => Promise<void>,
): void;
}
+1 -2
View File
@@ -1,13 +1,12 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"importHelpers": true,
"lib": ["DOM", "ES5", "ES6", "ES7"],
"allowSyntheticDefaultImports": true,