From a68ae9f9bce1c4fce18991518c2b36e4e9e540e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?He=CC=84sperus?= Date: Mon, 17 Aug 2026 23:58:43 +0800 Subject: [PATCH 1/3] feat: modernize UI --- .oxlintrc.json | 3 +- README.md | 4 +- docs/.vitepress/config.ts | 4 +- docs/.vitepress/i18n.ts | 2 +- docs/src/pages/en/deep-dive/architecture.md | 2 +- docs/src/pages/en/deep-dive/extensibility.md | 4 +- ...ent-panel.md => module-management-page.md} | 0 .../pages/en/development/develop-a-module.md | 2 +- docs/src/pages/en/usage/migration.md | 2 +- docs/src/pages/en/usage/modules.md | 2 +- docs/src/pages/en/usage/permissions.md | 2 +- docs/src/pages/en/usage/settings.md | 4 +- docs/src/pages/en/usage/why-sync-engine.md | 2 +- manifest.json | 4 +- modules.json | 8 +- packages/encryption/src/index.ts | 2 +- packages/encryption/src/setting.ts | 57 ++-- packages/i18n/src/ru/translations.ts | 1 - packages/i18n/src/zh-TW/translations.ts | 1 - packages/i18n/src/zh/translations.ts | 1 - packages/plugin/dist/dev.spec.d.ts | 2 +- ...KwI.spec.d.ts => index-CWzJYpA4.spec.d.ts} | 122 +++---- packages/plugin/dist/index.spec.d.ts | 2 +- .../src/components/UnknownModuleModal.ts | 2 +- .../src/components/module-management/App.tsx | 18 +- .../src/components/module-management/index.ts | 3 +- packages/plugin/src/en.ts | 15 +- packages/plugin/src/index.ts | 4 +- packages/plugin/src/modules/Bootstrap.ts | 16 +- packages/plugin/src/modules/Extensibility.ts | 99 +----- packages/plugin/src/modules/ModulesModal.ts | 141 -------- packages/plugin/src/modules/ProgressModal.ts | 4 +- packages/plugin/src/modules/Registrar.ts | 12 +- packages/plugin/src/settings/controls.ts | 120 +++---- packages/plugin/src/settings/development.ts | 139 +++++--- packages/plugin/src/settings/features.ts | 152 +++++---- packages/plugin/src/settings/filter.ts | 100 +++--- .../plugin/src/settings/generate-entry.ts | 99 ------ packages/plugin/src/settings/head.ts | 217 +++++++------ packages/plugin/src/settings/miscellaneous.ts | 110 +++---- .../plugin/src/settings/module-management.ts | 89 ++++++ packages/plugin/src/settings/utils.ts | 109 +++++++ packages/s3/src/index.ts | 2 +- packages/s3/src/setting.ts | 301 ++++++++++-------- packages/smart-merge/src/index.ts | 3 +- packages/smart-merge/src/setting.ts | 113 +++---- packages/webdav/src/index.ts | 4 +- packages/webdav/src/setting.ts | 206 ++++++------ scripts/deploy-modules.ts | 2 +- skills/debug-module/SKILL.md | 1 + 50 files changed, 1139 insertions(+), 1175 deletions(-) rename docs/src/pages/en/deep-dive/{module-management-panel.md => module-management-page.md} (100%) rename packages/plugin/dist/{index-yE2TnKwI.spec.d.ts => index-CWzJYpA4.spec.d.ts} (96%) delete mode 100644 packages/plugin/src/modules/ModulesModal.ts delete mode 100644 packages/plugin/src/settings/generate-entry.ts create mode 100644 packages/plugin/src/settings/module-management.ts create mode 100644 packages/plugin/src/settings/utils.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index d8e80d02..370914dc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -121,7 +121,8 @@ "eslint/radix": "off", "unicorn/import-style": "off", "unicorn/no-process-exit": "off", - "eslint/one-var": ["warn", "never"] + "eslint/one-var": ["warn", "never"], + "unicorn/max-nested-calls": ["warn", { "max": 5 }] }, "env": { "builtin": true, diff --git a/README.md b/README.md index 7e586086..7300d9d7 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ If not all your devices have WebDAV Sync updated to 2.5.12 or later, you can go Sync Engine is a revolutionary solution for vault syncing. Its not only a syncing plugin, it is a modular platform that everyone can build upon. -The core ships the infrastructure, and all backends (WebDAV, S3, GDrive) and features (i18n, optimization, sync strategy) come from composable modules. You and your AI agents can build your own modules via convenient SDK, extend the plugin, contribute to community, all without modifying the source code. +The core ships the infrastructure, and all backends (WebDAV, S3, GDrive) and features (i18n, optimization, sync strategy) come from composable modules. You can build your own modules via convenient SDK, extend the plugin, contribute to community, all without modifying the source code. Access Sync Engine documentation at [`sync.consensia.cc`](https://sync.consensia.cc), which contains usage guides, existing modules, permission claims, benchmarking, and documentation on how to build a module. @@ -104,7 +104,7 @@ Sync Engine fits the gap: you want to choose your own storage, you want the plug ## Usage 1. Download and enable `Sync Engine` from Obsidian plugin store. -2. Open "Module management" panel, install needed translations, backends and optional features. +2. Open "Module management" setting, install needed translations, backends and optional features. 3. Fill the necessary information about your cloud service in the settings interface. 4. Start your first sync from command palette or ribbon button. 5. Review the sync tasks that will be performed. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 940dead4..1a6f448b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -142,8 +142,8 @@ const localeConfig = configGenerator((t) => { items: [ { link: `${deepDive}/file-tree`, text: t('fileTree') }, { - link: `${deepDive}/module-management-panel`, - text: t('moduleManagementPanel'), + link: `${deepDive}/module-management-page`, + text: t('moduleManagementPage'), }, ], text: t('userInterface'), diff --git a/docs/.vitepress/i18n.ts b/docs/.vitepress/i18n.ts index deb5f765..43c5a2df 100644 --- a/docs/.vitepress/i18n.ts +++ b/docs/.vitepress/i18n.ts @@ -29,7 +29,7 @@ const en = { 'All content licensed under the CC BY 4.0 License.', migrateFromV2: 'Migrate from V2', miscellaneous: 'Miscellaneous', - moduleManagementPanel: 'Module Management Panel', + moduleManagementPage: 'Module Management Page', modules: 'Modules', nativeName: 'English', permissions: 'Permissions', diff --git a/docs/src/pages/en/deep-dive/architecture.md b/docs/src/pages/en/deep-dive/architecture.md index 1ed38ef9..4c1d5780 100644 --- a/docs/src/pages/en/deep-dive/architecture.md +++ b/docs/src/pages/en/deep-dive/architecture.md @@ -71,7 +71,7 @@ The dependency direction is intentionally visible in the constructors. For examp External modules extend this same context at runtime. `Extensibility` verifies and imports an approved constructor, adds it to SynthKernel, merges its `moduleSettings`, and adds it to `allModules` so it participates in startup and disposal. Unloading invokes `dispose()`, removes the constructor, and dispatches `moduleUnloaded`. -The module loader is also a security boundary because modules are executable code. Trust, integrity verification, enablement, storage, and runtime privileges are specified in the [Extensibility Contract](./extensibility); module-management UI behavior is covered in [Module Management UI](./module-management-panel). +The module loader is also a security boundary because modules are executable code. Trust, integrity verification, enablement, storage, and runtime privileges are specified in the [Extensibility Contract](./extensibility); module-management UI behavior is covered in [Module Management UI](./module-management-page). ## Registration Pattern diff --git a/docs/src/pages/en/deep-dive/extensibility.md b/docs/src/pages/en/deep-dive/extensibility.md index 6d963cc7..ee9e86cf 100644 --- a/docs/src/pages/en/deep-dive/extensibility.md +++ b/docs/src/pages/en/deep-dive/extensibility.md @@ -22,11 +22,11 @@ The loader guards against: Module source URLs are stored in `settings.moduleSources`. The default source is `https://sync.consensia.cc/modules.json`, this is the official source hosted on GitHub pages, fully transparent. -Sync Engine officially hosts an alternative source `https://github.com/hesprs/sync-engine/raw/refs/heads/gh-pages/modules-alternative.json`. This source is identical to the main source except replaced all `sync.consensia.cc` to `hesprs.github.io/sync-engine`. Use this source when your firewall flags `sync.consensia.cc` as unsafe. +Sync Engine officially hosts an alternative source `https://raw.githubusercontent.com/hesprs/sync-engine/refs/heads/gh-pages/modules-alternative.json`. This source is identical to the main source except replaced all `sync.consensia.cc` to `hesprs.github.io/sync-engine`. Use this source when your firewall flags `sync.consensia.cc` as unsafe. ::: warning -Please avoid using two sources simultaneously, if you have decided to use `https://github.com/hesprs/sync-engine/raw/refs/heads/gh-pages/modules-alternative.json`, delete `https://sync.consensia.cc/modules.json` in the sources list. +Please avoid using two sources simultaneously, if you have decided to use `https://raw.githubusercontent.com/hesprs/sync-engine/refs/heads/gh-pages/modules-alternative.json`, delete `https://sync.consensia.cc/modules.json` in the sources list. ::: diff --git a/docs/src/pages/en/deep-dive/module-management-panel.md b/docs/src/pages/en/deep-dive/module-management-page.md similarity index 100% rename from docs/src/pages/en/deep-dive/module-management-panel.md rename to docs/src/pages/en/deep-dive/module-management-page.md diff --git a/docs/src/pages/en/development/develop-a-module.md b/docs/src/pages/en/development/develop-a-module.md index ddf250f5..f78cb487 100644 --- a/docs/src/pages/en/development/develop-a-module.md +++ b/docs/src/pages/en/development/develop-a-module.md @@ -150,7 +150,7 @@ So simply run `bun dev`, and your module will be rebuilt inside the right folder After rebuilding, you need to reload the module in module management UI to apply latest changes. Or you can use the [Hot Reload](https://github.com/pjeby/hot-reload) plugin to reload Sync Engine on each build, so your module will also be reloaded. -To reliably reload Sync Engine modules without triggering its integrity protection, you need to manually disable integrity verification in the module editor interface in [module management panel](../deep-dive/module-management-panel). Make sure you only disable the verification of the module you are developing, see [Security](../usage/security) for security implications. +To reliably reload Sync Engine modules without triggering its integrity protection, you need to manually disable integrity verification in the module editor interface in [module management page](../deep-dive/module-management-page). Make sure you only disable the verification of the module you are developing, see [Security](../usage/security) for security implications. ## Load CSS in a Module diff --git a/docs/src/pages/en/usage/migration.md b/docs/src/pages/en/usage/migration.md index 80c0220c..efd8b22f 100644 --- a/docs/src/pages/en/usage/migration.md +++ b/docs/src/pages/en/usage/migration.md @@ -40,7 +40,7 @@ If you need a more transparent migration process to see what is going on around 1. Go into Obsidian, use WebDAV Sync to sync all your devices to ensure they have aligned copies of your data. If it shows the migration prompt, choose "cancel" directly. 2. Disable and delete WebDAV Sync on every devices. 3. Install Sync Engine from Obsidian plugin store. -4. Install the `WebDAV` module from the module management panel in Sync Engine settings, configure your account on every devices. +4. Install the `WebDAV` module from the module management page in Sync Engine settings, configure your account on every devices. 5. If you previously **enabled encryption**, please download the Encryption module and configure the encryption password. The manually delete the remote base directory on your WebDAV management UI. Choose one of your devices with better internet connection to sync your encrypted full vault to the remote. 6. If you are not using encryption previously, **go to Sync Engine settings and disable "Asymmetric storage", you do not need to delete any data anywhere.** 7. Perform sync on all other devices, Sync Engine should scan the remote folder and picks up the aligned state, populates its internal records only and shows "Already synced". diff --git a/docs/src/pages/en/usage/modules.md b/docs/src/pages/en/usage/modules.md index dee8f413..7daa1e2f 100644 --- a/docs/src/pages/en/usage/modules.md +++ b/docs/src/pages/en/usage/modules.md @@ -6,6 +6,6 @@ import ModuleCards from '@/components/ModuleCards.vue'; Below shows all currently available modules, the recommended way is to download them in the plugin module management UI. You can also download the JavaScript binaries here. -The official module source is `https://sync.consensia.cc/modules.json`. If your machine fails to fetch this module source (often due to aggressive firewall), you can try adding `https://github.com/hesprs/sync-engine/raw/refs/heads/gh-pages/modules-alternative.json` (and delete the original one) to your module sources. This source is the official alternative using GitHub's domain. +The official module source is `https://sync.consensia.cc/modules.json`. If your machine fails to fetch this module source (often due to aggressive firewall), you can try adding `https://raw.githubusercontent.com/hesprs/sync-engine/refs/heads/gh-pages/modules-alternative.json` (and delete the original one) to your module sources. This source is the official alternative using GitHub's domain. diff --git a/docs/src/pages/en/usage/permissions.md b/docs/src/pages/en/usage/permissions.md index f071b6d0..7b3df0a6 100644 --- a/docs/src/pages/en/usage/permissions.md +++ b/docs/src/pages/en/usage/permissions.md @@ -29,7 +29,7 @@ Sync Engine only makes network requests for the two purposes below: Requests made for syncing purpose only happen during sync runs. -Module sources are fetched only when automatic module update starts or user opens the module management panel. Sync Engine only fetches module sources defined in the "Module sources" setting. Modules are only downloaded when the user manually downloads a module or during module auto update. +Module sources are fetched only when automatic module update starts or user opens the module management page. Sync Engine only fetches module sources defined in the "Module sources" setting. Modules are only downloaded when the user manually downloads a module or during module auto update. The only default module source is `https://sync.consensia.cc/modules.json`, `sync.consensia.cc` is hosted on GitHub pages, whose source code is 100% transparent and verifiable in Sync Engine GitHub repository. diff --git a/docs/src/pages/en/usage/settings.md b/docs/src/pages/en/usage/settings.md index ab169aca..1f3e29ef 100644 --- a/docs/src/pages/en/usage/settings.md +++ b/docs/src/pages/en/usage/settings.md @@ -12,9 +12,9 @@ Choose the installed module that connects Sync Engine to your storage service. T ### Module Management -Open the module management panel. From there, you can install, update, enable, disable, remove, or edit modules, and manage their update sources. Modules provide storage backends and extra sync strategies. Review [Security](./security) before installing modules from sources you do not control. +Open the module management page. From there, you can install, update, enable, disable, remove, or edit modules, and manage their update sources. Modules provide storage backends and extra sync strategies. Review [Security](./security) before installing modules from sources you do not control. -The [module management panel](../deep-dive/module-management-panel) consists of a top bar and the module card list. You can select to show installed only and edit module sources at the hamburger button beside the search bar. +The [module management page](../deep-dive/module-management-page) consists of a top bar and the module card list. You can select to show installed only and edit module sources at the hamburger button beside the search bar. ### Auto-Update Modules diff --git a/docs/src/pages/en/usage/why-sync-engine.md b/docs/src/pages/en/usage/why-sync-engine.md index 6530e55b..a19d2755 100644 --- a/docs/src/pages/en/usage/why-sync-engine.md +++ b/docs/src/pages/en/usage/why-sync-engine.md @@ -70,7 +70,7 @@ Sync Engine core offers necessary features to ensure the extensibility and perfo It is simple to start using Sync Engine: 1. Download and enable `Sync Engine` from Obsidian plugin store. -2. Open "Module management" panel, install needed translations, backends and optional features. +2. Open "Module management" page, install needed translations, backends and optional features. 3. Fill the necessary information about your cloud service in the settings interface. 4. Start your first sync from command palette or ribbon button. 5. Review the sync tasks that will be performed. diff --git a/manifest.json b/manifest.json index f74fd3ca..6ea798ef 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "id": "sync-engine", "name": "Sync Engine", - "version": "3.0.6", - "minAppVersion": "1.12.3", + "version": "3.1.0", + "minAppVersion": "1.13.0", "authorUrl": "https://hesprs.github.io", "description": "The next-generation syncing plugin: Fast · Free · Extend with Modules. Supports WebDAV and S3.", "author": "Hēsperus", diff --git a/modules.json b/modules.json index a35580e5..b230715d 100644 --- a/modules.json +++ b/modules.json @@ -6,7 +6,7 @@ "description": "WebDAV backend support.", "icon": "server", "main": "https://sync.consensia.cc/modules/webdav.js", - "minPluginVersion": "3.0.0" + "minPluginVersion": "3.1.0" }, { "id": "s3", @@ -15,7 +15,7 @@ "description": "S3 and S3-compatible backend support.", "icon": "server", "main": "https://sync.consensia.cc/modules/s3.js", - "minPluginVersion": "3.0.0" + "minPluginVersion": "3.1.0" }, { "id": "encryption", @@ -24,7 +24,7 @@ "description": "Client-side encrypt vault files before uploading to backend.", "icon": "key-round", "main": "https://sync.consensia.cc/modules/encryption.js", - "minPluginVersion": "3.0.0" + "minPluginVersion": "3.1.0" }, { "id": "i18n-zh", @@ -60,6 +60,6 @@ "description": "Smart merge conflict resolution strategy that applies recursive three-way merge.", "icon": "combine", "main": "https://sync.consensia.cc/modules/smart-merge.js", - "minPluginVersion": "3.0.0" + "minPluginVersion": "3.1.0" } ] diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts index 29c2d1b2..63ad74b5 100644 --- a/packages/encryption/src/index.ts +++ b/packages/encryption/src/index.ts @@ -54,7 +54,7 @@ export default class Encryption { priority: 7919, }), registerSetting({ - apply: (el) => encryptionSetting(el, this.ctx as Context, this.moduleSettings), + apply: () => encryptionSetting(this.ctx as Context, this.moduleSettings), priority: 1355, }), ); diff --git a/packages/encryption/src/setting.ts b/packages/encryption/src/setting.ts index 1926fde8..e8f6c66b 100644 --- a/packages/encryption/src/setting.ts +++ b/packages/encryption/src/setting.ts @@ -1,8 +1,8 @@ import type { EncryptionSettings } from '@'; import type { Context, Fragment, MaybePromise, Translate } from '@hesprs/sync-engine-sdk'; -import type { App } from 'obsidian'; +import type { App, SettingDefinitionItem } from 'obsidian'; import { setNeedMigration } from '@hesprs/sync-engine-sdk'; -import { SecretComponent, Setting } from 'obsidian'; +import { SecretComponent } from 'obsidian'; export type EncryptionTranslations = { encryption: string; @@ -11,7 +11,6 @@ export type EncryptionTranslations = { }; export default function encryptionSetting( - el: HTMLElement, ctx: { translate: Translate; app: App; @@ -19,27 +18,35 @@ export default function encryptionSetting( recordStoreExists: () => MaybePromise; }, settings: EncryptionSettings, -) { +): Array { const { translate, app, saveSettings, recordStoreExists } = ctx; - - new Setting(el) - .setName(translate('encryption')) - .setDesc(translate('encryptionDescription')) - .addComponent((element) => - new SecretComponent(app, element).setValue(settings.password).onChange((value) => { - settings.password = value; - void saveSettings(); - }), - ) - .addToggle((toggle) => - setNeedMigration(ctx as Context, { - apply: (value) => { - settings.enabled = value; - void saveSettings(); - }, - content: (value) => translate('encryptionMigration', value ? 'enable' : 'disable'), - needMigration: recordStoreExists, - toggle: toggle.setValue(settings.enabled), - }), - ); + return [ + { + desc: translate('encryptionDescription'), + name: translate('encryption'), + render: (setting) => { + setting + .addComponent((element) => + new SecretComponent(app, element) + .setValue(settings.password) + .onChange((value) => { + settings.password = value; + void saveSettings(); + }), + ) + .addToggle((toggle) => + setNeedMigration(ctx as Context, { + apply: (value) => { + settings.enabled = value; + void saveSettings(); + }, + content: (value) => + translate('encryptionMigration', value ? 'enable' : 'disable'), + needMigration: recordStoreExists, + toggle: toggle.setValue(settings.enabled), + }), + ); + }, + }, + ]; } diff --git a/packages/i18n/src/ru/translations.ts b/packages/i18n/src/ru/translations.ts index 9126906d..c3a19cf8 100644 --- a/packages/i18n/src/ru/translations.ts +++ b/packages/i18n/src/ru/translations.ts @@ -233,7 +233,6 @@ const ru: Translations = { 'Отображать всплывающее уведомление на мобильных устройствах во время синхронизации. Заменяет строку состояния, используемую на ПК.', official: 'Официальный', omittedInvalidEntry: 'Пропущено недействительных записей: {{count}}.', - openPanel: 'Открыть панель', realtimeSync: 'Синхронизация в реальном времени', realtimeSyncDescription: 'Запускать синхронизацию автоматически сразу после изменения файлов. Измените задержку между изменением файла и запуском синхронизации в поле ниже.', diff --git a/packages/i18n/src/zh-TW/translations.ts b/packages/i18n/src/zh-TW/translations.ts index a1b04bc8..2bf6b316 100644 --- a/packages/i18n/src/zh-TW/translations.ts +++ b/packages/i18n/src/zh-TW/translations.ts @@ -225,7 +225,6 @@ const zhTW: Translations = { noticeStatusOnMobileDescription: '同步進行時於行動裝置上顯示通知訊息(取代桌面版的狀態列)。', official: '官方', omittedInvalidEntry: '已忽略 {{count}} 項無效條目。', - openPanel: '開啟面板', realtimeSync: '即時同步', realtimeSyncDescription: '當檔案經修改後立即自動觸發同步。請在欄位中修改檔案變更到觸發同步之間的延遲時間。', diff --git a/packages/i18n/src/zh/translations.ts b/packages/i18n/src/zh/translations.ts index ceb53a76..c1d53537 100644 --- a/packages/i18n/src/zh/translations.ts +++ b/packages/i18n/src/zh/translations.ts @@ -212,7 +212,6 @@ const zh: Translations = { '同步进行时在移动设备上显示通知提示。在桌面端则会替换状态栏显示。', official: '官方', omittedInvalidEntry: '已忽略 {{count}} 条无效条目。', - openPanel: '打开面板', realtimeSync: '实时同步', realtimeSyncDescription: '文件一旦修改即刻自动触发同步。在输入框中修改文件修改到触发同步之间的延迟时间。', diff --git a/packages/plugin/dist/dev.spec.d.ts b/packages/plugin/dist/dev.spec.d.ts index a0ee83c7..60675c6b 100644 --- a/packages/plugin/dist/dev.spec.d.ts +++ b/packages/plugin/dist/dev.spec.d.ts @@ -1,4 +1,4 @@ -import { Ct as FolderStat, Dt as RecordStatsMap, Et as RecordStat, J as TaskNames, M as Decider, Ot as Stat, St as FileStat, f as Request, kt as StatsMap, p as RequestParam, ut as Fs, vt as RootFs, xt as Binary, yt as WrappedFs } from "./index-yE2TnKwI.spec.js"; +import { Ct as FolderStat, Dt as RecordStatsMap, Et as RecordStat, J as TaskNames, M as Decider, Ot as Stat, St as FileStat, f as Request, kt as StatsMap, p as RequestParam, ut as Fs, vt as RootFs, xt as Binary, yt as WrappedFs } from "./index-CWzJYpA4.spec.js"; //#region src/sdk/debug-wrapper.d.ts declare function debugWrapper(original: Fs, log: (content: string) => void): WrappedFs; //#endregion diff --git a/packages/plugin/dist/index-yE2TnKwI.spec.d.ts b/packages/plugin/dist/index-CWzJYpA4.spec.d.ts similarity index 96% rename from packages/plugin/dist/index-yE2TnKwI.spec.d.ts rename to packages/plugin/dist/index-CWzJYpA4.spec.d.ts index de70e42d..c343da90 100644 --- a/packages/plugin/dist/index-yE2TnKwI.spec.d.ts +++ b/packages/plugin/dist/index-CWzJYpA4.spec.d.ts @@ -1,4 +1,4 @@ -import { App, Command, EventRef, IconName, ListedFiles, Modal, Plugin, RequestUrlParam, Stat, ToggleComponent } from "obsidian"; +import { App, Command, EventRef, IconName, ListedFiles, Modal, Plugin, RequestUrlParam, SettingDefinitionItem, Stat, ToggleComponent } from "obsidian"; //#region test/e2e-utils.d.ts type General$1 = any; //#endregion @@ -852,7 +852,6 @@ type HeadSettingTranslations = { moduleAutoUpdateDescription: string; moduleManagement: string; moduleManagementDescription: string; - openPanel: string; backend: string; backendDescription: string; syncStrategy: string; @@ -882,6 +881,45 @@ type MiscellaneousSettingTranslations = { edit: string; }; //#endregion +//#region src/components/module-management/index.d.ts +type ModuleManagementTranslations = { + disableModule: string; + downloadModule: string; + enableModule: string; + installed: string; + loadingModules: string; + noInstalledModulesFound: string; + noMatchingModulesFound: string; + noModulesAvailable: string; + updateAvailable: string; + updateModule: string; + deleteModule: string; + editModuleInformation: string; + official: string; +}; +//#endregion +//#region src/components/SourceEditorModal.d.ts +type SourceEditorTranslations = { + add: string; + cancel: string; + editSources: string; + omittedInvalidEntry: string; + moduleSourcePlaceholder: string; + remove: string; + save: string; + sourcesDescription: string; + httpInsecureWarning: string; +}; +//#endregion +//#region src/settings/module-management.d.ts +type ModulesManagementTranslations = ModuleManagementTranslations & SourceEditorTranslations & { + searchModules: string; + editSources: string; + moduleManagement: string; + showInstalledOnly: string; + configurations: string; +}; +//#endregion //#region src/modules/Bootstrap.d.ts type CustomHeaders = Array<{ type: 'plaintext' | 'secret'; @@ -915,7 +953,7 @@ declare class Bootstrap { keepRemote: string; renameAndKeepBoth: string; skip: string; - } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations; + } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations & ModulesManagementTranslations; readonly settings: { maxMemoryConsumption: TogglableValue; maxRequestConcurrency: TogglableValue; @@ -923,6 +961,7 @@ declare class Bootstrap { realtimeSyncFastMode: boolean; asymmetricStorage: boolean; customHeaders: CustomHeaders; + moduleSources: Array; }; constructor(ctx: { app: App; @@ -949,79 +988,6 @@ declare class Bootstrap { readonly dispose: () => void; } //#endregion -//#region src/components/module-management/index.d.ts -type ModuleManagementTranslations = { - disableModule: string; - downloadModule: string; - enableModule: string; - installed: string; - loadingModules: string; - noInstalledModulesFound: string; - noMatchingModulesFound: string; - noModulesAvailable: string; - updateAvailable: string; - updateModule: string; - deleteModule: string; - editModuleInformation: string; - official: string; -}; -//#endregion -//#region src/components/SourceEditorModal.d.ts -type SourceEditorTranslations = { - add: string; - cancel: string; - editSources: string; - omittedInvalidEntry: string; - moduleSourcePlaceholder: string; - remove: string; - save: string; - sourcesDescription: string; - httpInsecureWarning: string; -}; -//#endregion -//#region src/modules/ModulesModal.d.ts -type ModulesModalTranslations = ModuleManagementTranslations & SourceEditorTranslations & { - searchModules: string; - editSources: string; - moduleManagement: string; - showInstalledOnly: string; - configurations: string; -}; -declare class ModulesModal extends Modal { - private readonly ctx; - private readonly t; - private readonly modalCleanup; - private sourceEditorModal?; - private showInstalledOnly; - constructor(ctx: { - app: App; - translate: Translate; - saveSettings: () => Promise; - fetchSources: (manual?: boolean) => Promise>; - discoveredModules: Map; - loadedModules: Map; - downloadModule: (meta: AugmentedModuleMeta) => Promise; - deleteModule: (id: string) => Promise; - loadModule: (meta: AugmentedModuleMeta, start?: boolean) => Promise; - unloadModule: (id: string) => void; - enableModule: (id: string) => Promise; - disableModule: (id: string) => void; - updateModuleMeta: (meta: AugmentedModuleMeta) => Promise; - }); - readonly i18n: ModulesModalTranslations; - readonly settings: { - moduleSources: Array; - }; - root: { - closeModuleManagement: () => void; - openModuleManagement: () => void; - }; - onOpen(): void; - onClose(): void; - private readonly openSourceEditorModal; - dispose(): void; -} -//#endregion //#region src/modules/ProgressModal.d.ts type DeleteConfirmReturn = { delete: Array; @@ -1114,7 +1080,7 @@ declare class Scheduler { } //#endregion //#region src/index.d.ts -declare const internalModules: readonly [typeof EventBus, typeof I18n, typeof Storage, typeof Extensibility, typeof Registrar, typeof Sync, typeof Observability, typeof Scheduler, typeof ProgressModal, typeof ModulesModal, typeof Bootstrap]; +declare const internalModules: readonly [typeof EventBus, typeof I18n, typeof Storage, typeof Extensibility, typeof Registrar, typeof Sync, typeof Observability, typeof Scheduler, typeof ProgressModal, typeof Bootstrap]; type InternalModules = typeof internalModules; type MergeKeys = 'settings' | 'root' | 'events' | 'i18n'; type Context = Context$1; type OptimizerEntry = OrderedApplyEntry; type SettingEntry = { priority: number; - apply: (el: HTMLElement) => void; + apply: () => Array; }; type RequestParam = Omit & { body?: string | Binary; diff --git a/packages/plugin/dist/index.spec.d.ts b/packages/plugin/dist/index.spec.d.ts index 2123537a..a32b09bc 100644 --- a/packages/plugin/dist/index.spec.d.ts +++ b/packages/plugin/dist/index.spec.d.ts @@ -1,2 +1,2 @@ -import { $ as TranslationResource, A as SyncTerminateReason, B as MoveRemote, C as SelectFromContext, Ct as FolderStat, D as writeWithValue, Dt as RecordStatsMap, E as readWithSize, Et as RecordStat, F as Upload, G as BaseTask, H as Download, I as ResolveConflict, J as TaskNames, K as ConflictResolver, L as RemoveRemote, M as Decider, N as DeciderInput, O as prefixWrapper, Ot as Stat, P as TaskFactory, Q as Translate, R as RemoveRecord, S as ModuleMeta, St as FileStat, T as pipe, Tt as Progress, U as CreateRemoteDir, V as MoveLocal, W as AddRecord, X as Fragment, Y as RecordStore, Z as ObsidianLanguageCode, _ as Events, _t as OutputAtom, a as FsWrapperEntry, at as StoreOperations, b as ExistingMemoryDB, bt as WriteAtom, c as RemoteFsEntry, ct as CustomAtom, d as RemoteRequestMiddlewareEntry, dt as InputAtom, et as Dispatch, f as Request, ft as ListReporter, g as Context, gt as OptimizerOutput, h as SettingEntry, ht as OptimizerInput, i as DeciderEntry, it as StoreAsync, j as CreateLocalDir, k as setNeedMigration, kt as StatsMap, l as RemoteLister, lt as DeleteAtom, m as RequestResponse, mt as MoveAtom, n as CheckConnectionResult, nt as DatabaseAsync, o as LocalRequestMiddlewareEntry, ot as StoreSync, p as RequestParam, pt as MkdirAtom, q as ConflictResolverPayload, r as ConflictResolverEntry, rt as DatabaseSync, s as OptimizerEntry, st as BatchOptimizer, t as VaultRequest, tt as On, u as RemoteListerEntry, ut as Fs, v as Settings, vt as RootFs, w as digOriginal, wt as MaybePromise, x as AugmentedModuleMeta, xt as Binary, y as Translations, yt as WrappedFs, z as RemoveLocal } from "./index-yE2TnKwI.spec.js"; +import { $ as TranslationResource, A as SyncTerminateReason, B as MoveRemote, C as SelectFromContext, Ct as FolderStat, D as writeWithValue, Dt as RecordStatsMap, E as readWithSize, Et as RecordStat, F as Upload, G as BaseTask, H as Download, I as ResolveConflict, J as TaskNames, K as ConflictResolver, L as RemoveRemote, M as Decider, N as DeciderInput, O as prefixWrapper, Ot as Stat, P as TaskFactory, Q as Translate, R as RemoveRecord, S as ModuleMeta, St as FileStat, T as pipe, Tt as Progress, U as CreateRemoteDir, V as MoveLocal, W as AddRecord, X as Fragment, Y as RecordStore, Z as ObsidianLanguageCode, _ as Events, _t as OutputAtom, a as FsWrapperEntry, at as StoreOperations, b as ExistingMemoryDB, bt as WriteAtom, c as RemoteFsEntry, ct as CustomAtom, d as RemoteRequestMiddlewareEntry, dt as InputAtom, et as Dispatch, f as Request, ft as ListReporter, g as Context, gt as OptimizerOutput, h as SettingEntry, ht as OptimizerInput, i as DeciderEntry, it as StoreAsync, j as CreateLocalDir, k as setNeedMigration, kt as StatsMap, l as RemoteLister, lt as DeleteAtom, m as RequestResponse, mt as MoveAtom, n as CheckConnectionResult, nt as DatabaseAsync, o as LocalRequestMiddlewareEntry, ot as StoreSync, p as RequestParam, pt as MkdirAtom, q as ConflictResolverPayload, r as ConflictResolverEntry, rt as DatabaseSync, s as OptimizerEntry, st as BatchOptimizer, t as VaultRequest, tt as On, u as RemoteListerEntry, ut as Fs, v as Settings, vt as RootFs, w as digOriginal, wt as MaybePromise, x as AugmentedModuleMeta, xt as Binary, y as Translations, yt as WrappedFs, z as RemoveLocal } from "./index-CWzJYpA4.spec.js"; export { type AddRecord, type AugmentedModuleMeta, type BaseTask, type BatchOptimizer, type Binary, type CheckConnectionResult, type ConflictResolver, type ConflictResolverEntry, type ConflictResolverPayload, type Context, type CreateLocalDir, type CreateRemoteDir, type CustomAtom, type DatabaseAsync, type DatabaseSync, type Decider, type DeciderEntry, type DeciderInput, type DeleteAtom, type Dispatch, type Download, type Events, type ExistingMemoryDB, type FileStat, type FolderStat, type Fragment, type Fs, type FsWrapperEntry, type InputAtom, type ListReporter, type LocalRequestMiddlewareEntry, type MaybePromise, type MkdirAtom, type ModuleMeta, type MoveAtom, type MoveLocal, type MoveRemote, type ObsidianLanguageCode, type On, type OptimizerEntry, type OptimizerInput, type OptimizerOutput, type OutputAtom, type Progress, type RecordStat, type RecordStatsMap, type RecordStore, type RemoteFsEntry, type RemoteLister, type RemoteListerEntry, type RemoteRequestMiddlewareEntry, type RemoveLocal, type RemoveRecord, type RemoveRemote, type Request, type RequestParam, type RequestResponse, type ResolveConflict, type RootFs, SelectFromContext, type SettingEntry, type Settings, type Stat, type StatsMap, type StoreAsync, type StoreOperations, type StoreSync, type SyncTerminateReason, type TaskFactory, type TaskNames, type Translate, type TranslationResource, type Translations, type Upload, type VaultRequest, type WrappedFs, type WriteAtom, digOriginal, pipe, prefixWrapper, readWithSize, setNeedMigration, writeWithValue }; \ No newline at end of file diff --git a/packages/plugin/src/components/UnknownModuleModal.ts b/packages/plugin/src/components/UnknownModuleModal.ts index 44d123fc..dbb4567a 100644 --- a/packages/plugin/src/components/UnknownModuleModal.ts +++ b/packages/plugin/src/components/UnknownModuleModal.ts @@ -80,7 +80,7 @@ export default class UnknownModuleModal extends Modal { .addButton((button) => button .setButtonText(translate('delete')) - .setWarning() + .setDestructive() .setCta() .onClick(async () => { await app.vault.adapter.remove(path); diff --git a/packages/plugin/src/components/module-management/App.tsx b/packages/plugin/src/components/module-management/App.tsx index 99e0304b..d8a6ff19 100644 --- a/packages/plugin/src/components/module-management/App.tsx +++ b/packages/plugin/src/components/module-management/App.tsx @@ -21,6 +21,7 @@ export default function App(props: { const [showInstalledOnly, setShowInstalledOnlySignal] = createSignal(false); const [hasLoaded, setHasLoaded] = createSignal(false); const [isLoading, setIsLoading] = createSignal(false); + const [isPluginOutdated, setIsPluginOutdated] = createSignal(props.ctx.pluginOutdated); const [pendingByName, setPendingByName] = createSignal< Record >({}); @@ -35,11 +36,17 @@ export default function App(props: { if (props.isUnmounted()) return; setIsLoading(true); try { - const modules = await props.ctx.fetchSources(); + const seen = new Set(); + const modules = (await props.ctx.fetchSources()).filter(({ id }) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); if (props.isUnmounted()) return; setSourceModules(modules); syncSnapshots(); setHasLoaded(true); + setIsPluginOutdated(props.ctx.pluginOutdated); } finally { if (!props.isUnmounted()) setIsLoading(false); } @@ -90,14 +97,10 @@ export default function App(props: { const unsubscribeShowInstalledOnly = props.hooks.onShowInstalledOnlyChange.subscribe( (enabled) => setShowInstalledOnlySignal(enabled), ); - const unsubscribeSourcesChange = props.hooks.onSourcesChange.subscribe(() => { - void refreshSources(); - }); onCleanup(() => { unsubscribeQuery(); unsubscribeShowInstalledOnly(); - unsubscribeSourcesChange(); }); onMount(() => { @@ -143,6 +146,11 @@ export default function App(props: { + +
+ {props.ctx.translate('someModulesHidden')} +
+
); } diff --git a/packages/plugin/src/components/module-management/index.ts b/packages/plugin/src/components/module-management/index.ts index 49d86841..39a9974a 100644 --- a/packages/plugin/src/components/module-management/index.ts +++ b/packages/plugin/src/components/module-management/index.ts @@ -21,12 +21,12 @@ export type ModuleManagementTranslations = { deleteModule: string; editModuleInformation: string; official: string; + someModulesHidden: string; }; export type ModuleManagementHooks = { onQuery: Hook<[string]>; onShowInstalledOnlyChange: Hook<[boolean]>; - onSourcesChange: Hook; }; export type ModuleManagementContext = { @@ -42,6 +42,7 @@ export type ModuleManagementContext = { translate: Translate; updateModuleMeta: (meta: AugmentedModuleMeta) => Promise; app: ObsidianApp; + pluginOutdated: boolean; }; export function mountModuleManagementList( diff --git a/packages/plugin/src/en.ts b/packages/plugin/src/en.ts index 1146941a..19395ebe 100644 --- a/packages/plugin/src/en.ts +++ b/packages/plugin/src/en.ts @@ -64,7 +64,6 @@ const en: Translations = { 'Sync Engine records sync states to resolve sync operations between local and remote files. This option allows you to clear records. Warning: this action is likely to cause changes in sync decisions.', completed: 'Completed', completedNoop: 'Already synced', - configurations: 'Configurations', configure: 'Configure', confirm: 'Confirm', confirmDeleteDescription: @@ -150,7 +149,7 @@ const en: Translations = { icon: 'Icon', iconDescription: (frag) => { frag.appendText( - 'Set the icon for this module to be displayed in the module management panel, full icons can be found in ', + 'Set the icon for this module to be displayed in the module management page, full icons can be found in ', ); frag.createEl('a', { attr: { href: 'https://lucide.dev/icons/' }, @@ -218,8 +217,11 @@ const en: Translations = { moduleAutoUpdateDescription: 'Automatically update installed modules from module sources.', moduleManagement: 'Module management', moduleManagementDescription: - 'Manage modules in a dedicated panel. You can install, uninstall, update, enable, disable, edit modules, or edit module sources.', + 'Manage modules in a dedicated page. You can install, uninstall, update, enable, disable, and edit modules.', moduleSourcePlaceholder: 'https://example.com/modules.json', + moduleSources: 'Module Sources', + moduleSourcesDescription: + 'Edit module sources from which the module catalog is obtained. In this way you can install third-party Sync Engine modules.', moveLocal: 'Move local', moveRemote: 'Move remote', name: 'Name', @@ -227,13 +229,13 @@ const en: Translations = { noInstalledModulesFound: 'No installed modules found.', noMatchingModulesFound: 'No matching modules found.', noModulesAvailable: 'No modules available.', + noSourceConfigured: 'No source configured', none: 'None', noticeStatusOnMobile: 'Notice sync status on mobile', noticeStatusOnMobileDescription: 'Display a notice on mobile devices when synchronization is in progress. Replaces the status bar on desktop.', official: 'Official', omittedInvalidEntry: 'Omitted {{count}} invalid entry(s).', - openPanel: 'Open panel', realtimeSync: 'Realtime sync', realtimeSyncDescription: 'Trigger syncs automatically as soon as files are modified. Alter the delay between a file being modified and the sync being triggered in the field.', @@ -259,6 +261,8 @@ const en: Translations = { showInstalledOnly: 'Show installed only', showProgress: 'Show progress', skip: 'Skip', + someModulesHidden: + 'Some modules are hidden since Sync Engine plugin is outdated, update to explore the full module catalog.', sourcesDescription: 'Add module source URLs. Empty and invalid rows are omitted when saved.', startMigration: 'Start migration', startNonInteractiveSync: 'Start non-interactive sync', @@ -279,7 +283,7 @@ const en: Translations = { p1.appendText('Sync Engine detected an installed module named '); p1.createEl('code', { text: fileName }); p1.appendText( - ' in its module directory. This module is neither installed in Sync Engine module panel, nor registered anywhere to be exempt from provenance validation. ', + ' in its module directory. This module is neither installed in Sync Engine module management page, nor registered anywhere to be exempt from provenance validation. ', ); p1.createEl('strong', { text: 'Please review following information before proceeding:' }); const ul = frag @@ -318,6 +322,7 @@ const en: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: 'Upload', walkingRemote: 'Discovering remote files', + xEnabled: '{{x}} enabled', }; export default en; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index f887d474..34e4b0da 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -9,7 +9,6 @@ import Bootstrap from './modules/Bootstrap'; import EventBus from './modules/EventBus'; import Extensibility, { OFFICIAL_SOURCE } from './modules/Extensibility'; import I18n from './modules/I18n'; -import ModulesModal from './modules/ModulesModal'; import Observability from './modules/Observability'; import ProgressModal from './modules/ProgressModal'; import Registrar from './modules/Registrar'; @@ -27,7 +26,6 @@ const internalModules = [ Observability, Scheduler, ProgressModal, - ModulesModal, Bootstrap, ] as const; @@ -119,11 +117,11 @@ export default class SyncEngine extends Plugin { }).__assign__({ settings }); this.settings = this.context.settings; await this.context.loadAllModules(); - this.context.addSettingTab(this); for (const module of this.allModules) { const instance = this.context.__getModule__(module); if ('start' in instance) instance.start(); } + this.context.addSettingTab(this); } onunload() { diff --git a/packages/plugin/src/modules/Bootstrap.ts b/packages/plugin/src/modules/Bootstrap.ts index f610d29f..91cc1060 100644 --- a/packages/plugin/src/modules/Bootstrap.ts +++ b/packages/plugin/src/modules/Bootstrap.ts @@ -13,6 +13,7 @@ import type { FeaturesSettingTranslations } from '@/settings/features'; import type { FilterSettingTranslations } from '@/settings/filter'; import type { HeadSettingTranslations } from '@/settings/head'; import type { MiscellaneousSettingTranslations } from '@/settings/miscellaneous'; +import type { ModulesTranslations } from '@/settings/module-management'; import type { Stat, TogglableValue } from '@/types'; import en from '@/en'; import { @@ -103,7 +104,8 @@ export default class Bootstrap { HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & - FileTreeTranslations; + FileTreeTranslations & + ModulesTranslations; declare readonly settings: { maxMemoryConsumption: TogglableValue; maxRequestConcurrency: TogglableValue; @@ -394,22 +396,22 @@ export default class Bootstrap { resolver: () => {}, }); - registerSetting({ apply: (el) => headSettings(el, this.ctx as Context), priority: 0 }); + registerSetting({ apply: () => headSettings(this.ctx as Context), priority: 0 }); registerSetting({ - apply: (el) => featuresSettings(el, this.ctx as Context), + apply: () => featuresSettings(this.ctx as Context), priority: 1000, }); registerSetting({ - apply: (el) => controlsSettings(el, this.ctx as Context), + apply: () => controlsSettings(this.ctx as Context), priority: 2000, }); - registerSetting({ apply: (el) => filterSettings(el, this.ctx as Context), priority: 3000 }); + registerSetting({ apply: () => filterSettings(this.ctx as Context), priority: 3000 }); registerSetting({ - apply: (el) => miscellaneousSettings(el, this.ctx as Context), + apply: () => miscellaneousSettings(this.ctx as Context), priority: 4000, }); registerSetting({ - apply: (el) => developmentSettings(el, this.ctx as Context), + apply: () => developmentSettings(this.ctx as Context), priority: 5000, }); diff --git a/packages/plugin/src/modules/Extensibility.ts b/packages/plugin/src/modules/Extensibility.ts index df3a8ff3..7a54606c 100644 --- a/packages/plugin/src/modules/Extensibility.ts +++ b/packages/plugin/src/modules/Extensibility.ts @@ -1,16 +1,14 @@ import type { Context, Events, Translations } from '@'; -import type { App, DataAdapter } from 'obsidian'; +import type { App } from 'obsidian'; import type { Ref } from 'synthkernel'; import type { StoreOperations } from 'uni-kv'; import loadModule from '$/e2e-utils'; import hash from '@repo/shared/crypto'; -import { encodeURIComponent3986 } from '@repo/shared/path'; import obsidian, { Notice, requestUrl } from 'obsidian'; import { compare } from 'verkit'; import type { DatabaseAsync, StoreAsync } from '@/sdk'; import type { General } from '@/types'; import UnknownModuleModal from '@/components/UnknownModuleModal'; -import sha256 from '@/utils/sha-256'; import toErrorMessage from '@/utils/to-error-message'; import untilTrue from '@/utils/until-true'; import type { Dispatch } from './EventBus'; @@ -123,19 +121,6 @@ export default class Extensibility { this.moduleStore.entries().then((result) => new Map(result)), ]); - const legacyValues = Object.values(this.settings.modules); - if ( - files.some((str) => str.includes('~')) && - (!legacyValues.length || legacyValues.some((value) => typeof value === 'boolean')) - ) - await migrateModules({ - adapter, - baseDir: this.moduleDir, - fileList: files, - moduleStore: this.moduleStore, - settings: this.settings, - }); - folders.forEach((path) => factory.delete(path)); const foundModules = new Set(); files.forEach((path) => { @@ -286,11 +271,9 @@ export default class Extensibility { content.forEach((meta: unknown) => { if (!isValidMeta(meta)) return; const { id, minPluginVersion, icon } = meta; - if ( - (minPluginVersion && compare(VERSION, minPluginVersion) === -1) || - seenId.has(id) - ) - return; + if (minPluginVersion && compare(VERSION, minPluginVersion) === -1) + this.root.pluginOutdated = true; + if (seenId.has(id)) return; seenId.add(id); modules.push({ ...meta, @@ -371,6 +354,7 @@ export default class Extensibility { loadAllModules: this.loadAllModules, loadModule: this.loadModule, loadedModules: this.loadedModules, + pluginOutdated: false, unloadModule: this.unloadModule, updateModuleMeta: this.updateModuleMeta, updateModules: this.updateModules, @@ -397,76 +381,3 @@ function isValidMeta(meta: unknown): meta is ModuleMeta { /^[0-9a-f]*$/v.test(meta.integrity) ); } - -// TODO: remove after August 16 -async function migrateModules({ - moduleStore, - fileList, - adapter, - baseDir, - settings, -}: { - moduleStore: StoreAsync; - fileList: Array; - adapter: DataAdapter; - baseDir: string; - settings: { modules: Record }; -}) { - const moduleIdMap: Record = { - Encryption: 'encryption', - 'I18n Русский': 'i18n-ru', - 'I18n 简体中文': 'i18n-zh', - 'I18n 繁體中文': 'i18n-zh-TW', - 'Smart Merge': 'smart-merge', - WebDAV: 'webdav', - }; - const legacySettings = settings as unknown as { - modules: Record; - } & Record; - function parseModulePath(path: string) { - const name = path.slice(baseDir.length + 1, -MODULE_EXTENSION.length); - const segments = name.split('~').map((segment) => segment.normalize('NFC')); - return { name: segments[0], version: segments[1] }; - } - const getModulePath = (name: string) => `${baseDir}/${name}${MODULE_EXTENSION}`; - const dbWrites: Array<{ key: string; value: AugmentedModuleMeta }> = []; - const operations: Array<() => Promise> = [ - () => - moduleStore.batch( - dbWrites.map((write): StoreOperations => - Object.assign(write, { type: 'set' } as const), - ), - ), - ]; - await Promise.all( - fileList.map(async (path) => { - if (!path.includes('~')) return; - const { name, version } = parseModulePath(path); - const file = await adapter.read(path); - const key = moduleIdMap[name] ?? name; - const id = moduleIdMap[name] ?? name; - dbWrites.push({ - key, - value: { - description: '', - enabled: legacySettings.modules[name] ?? false, - icon: 'puzzle', - id, - integrity: await sha256(file), - main: `https://sync.consensia.cc/modules/${encodeURIComponent3986(name)}.js`, - name, - source: 'https://sync.consensia.cc/modules.json', - version, - }, - }); - settings.modules[id] = legacySettings[name]; - delete legacySettings[name]; - delete settings.modules[name]; - operations.push( - () => adapter.remove(path), - () => adapter.write(getModulePath(id), file), - ); - }), - ); - await Promise.all(operations.map((fn) => fn())); -} diff --git a/packages/plugin/src/modules/ModulesModal.ts b/packages/plugin/src/modules/ModulesModal.ts deleted file mode 100644 index dfb62da3..00000000 --- a/packages/plugin/src/modules/ModulesModal.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { App } from 'obsidian'; -import { Menu, Modal, SearchComponent, setIcon, setTooltip } from 'obsidian'; -import { hook } from 'synthkernel'; -import type { ModuleManagementTranslations } from '@/components/module-management'; -import type { SourceEditorTranslations } from '@/components/SourceEditorModal'; -import { mountModuleManagementList } from '@/components/module-management'; -import ModuleSourceEditorModal from '@/components/SourceEditorModal'; -import type { AugmentedModuleMeta } from './Extensibility'; -import type { Translate } from './I18n'; - -type ModulesModalTranslations = ModuleManagementTranslations & - SourceEditorTranslations & { - searchModules: string; - editSources: string; - moduleManagement: string; - showInstalledOnly: string; - configurations: string; - }; - -export default class ModulesModal extends Modal { - private readonly t: Translate; - private readonly modalCleanup: Array<() => void> = []; - private sourceEditorModal?: ModuleSourceEditorModal; - private showInstalledOnly = false; - - constructor( - private readonly ctx: { - app: App; - translate: Translate; - saveSettings: () => Promise; - fetchSources: (manual?: boolean) => Promise>; - discoveredModules: Map; - loadedModules: Map; - downloadModule: (meta: AugmentedModuleMeta) => Promise; - deleteModule: (id: string) => Promise; - loadModule: (meta: AugmentedModuleMeta, start?: boolean) => Promise; - unloadModule: (id: string) => void; - enableModule: (id: string) => Promise; - disableModule: (id: string) => void; - updateModuleMeta: (meta: AugmentedModuleMeta) => Promise; - }, - ) { - super(ctx.app); - this.t = ctx.translate; - this.containerEl.getElementsByClassName('modal-bg')[0].addClass('opacity-0!'); - this.modalEl.addClasses(['sync-engine-large-modal', 'shadow-none!']); - } - - declare readonly i18n: ModulesModalTranslations; - declare readonly settings: { moduleSources: Array }; - - root = { - closeModuleManagement: this.close.bind(this), - openModuleManagement: this.open.bind(this), - }; - - onOpen() { - this.setTitle(this.t('moduleManagement')); - const controlsEl = this.contentEl.createDiv('flex items-center gap-2 pb-4'); - const searchEl = controlsEl.createDiv('min-w-0 flex-1'); - const listEl = this.contentEl.createDiv('min-h-0 overflow-y-auto'); - - const onQuery = hook<[string]>(); - const onShowInstalledOnlyChange = hook<[boolean]>(); - const onSourcesChange = hook(); - - const search = new SearchComponent(searchEl) - .setPlaceholder(this.t('searchModules')) - .onChange(onQuery); - search.inputEl.addClass('w-full'); - search.inputEl.spellcheck = false; - - const menuButton = controlsEl.createEl('button', { - attr: { 'aria-label': this.t('editSources'), type: 'button' }, - cls: 'clickable-icon flex-shrink-0 rounded-md', - }); - setIcon(menuButton, 'menu'); - setTooltip(menuButton, this.t('configurations')); - menuButton.onClickEvent((event) => { - const menu = new Menu(); - menu.setNoIcon() - .setParentElement(menuButton) - .addItem((item) => { - item.setTitle(this.t('showInstalledOnly')) - .setChecked(this.showInstalledOnly) - .onClick(() => { - this.showInstalledOnly = !this.showInstalledOnly; - onShowInstalledOnlyChange(this.showInstalledOnly); - }); - }) - .addItem((item) => { - item.setTitle(this.t('editSources')).onClick(() => - this.openSourceEditorModal(onSourcesChange), - ); - }); - menu.showAtMouseEvent(event); - }); - - this.modalCleanup.push( - mountModuleManagementList(listEl, this.ctx, { - onQuery, - onShowInstalledOnlyChange, - onSourcesChange, - }), - () => { - onQuery.clear(); - onShowInstalledOnlyChange.clear(); - onSourcesChange.clear(); - }, - ); - onShowInstalledOnlyChange(this.showInstalledOnly); - } - - onClose() { - this.modalCleanup.splice(0).forEach((fn) => fn()); - this.contentEl.empty(); - } - - private readonly openSourceEditorModal = (cb: () => void) => { - this.sourceEditorModal?.close(); - this.sourceEditorModal = new ModuleSourceEditorModal( - (sources) => { - this.settings.moduleSources = sources; - void this.ctx.saveSettings(); - cb(); - }, - { - app: this.ctx.app, - translate: this.t, - }, - this.settings.moduleSources, - ).setCloseCallback(() => (this.sourceEditorModal = undefined)); - this.sourceEditorModal.open(); - }; - - dispose() { - this.sourceEditorModal?.close(); - this.sourceEditorModal = undefined; - this.close(); - } -} diff --git a/packages/plugin/src/modules/ProgressModal.ts b/packages/plugin/src/modules/ProgressModal.ts index 7a72a924..6d4b9db2 100644 --- a/packages/plugin/src/modules/ProgressModal.ts +++ b/packages/plugin/src/modules/ProgressModal.ts @@ -135,7 +135,7 @@ export default class ProgressModal extends Modal { .addButton((button) => { button .setButtonText(this.t('stopSync')) - .setWarning() + .setDestructive() .onClick(() => { this.dispatch('syncCanceled'); return new Promise((resolve) => { @@ -157,7 +157,7 @@ export default class ProgressModal extends Modal { .addButton((button) => { button .setButtonText(this.t('cancel')) - .setWarning() + .setDestructive() .onClick(() => this.close()); }) .addButton((button) => diff --git a/packages/plugin/src/modules/Registrar.ts b/packages/plugin/src/modules/Registrar.ts index 6041b7e0..b9433d7c 100644 --- a/packages/plugin/src/modules/Registrar.ts +++ b/packages/plugin/src/modules/Registrar.ts @@ -1,5 +1,5 @@ import type { Events } from '@'; -import type { App, Plugin, RequestUrlParam } from 'obsidian'; +import type { App, Plugin, RequestUrlParam, SettingDefinitionItem } from 'obsidian'; import type { StoreAsync } from 'uni-kv'; import { toArrayBuffer, toUint8Array } from '@repo/shared/binary'; import hash from '@repo/shared/crypto'; @@ -41,7 +41,7 @@ export type OptimizerEntry = OrderedApplyEntry; export type SettingEntry = { priority: number; - apply: (el: HTMLElement) => void; + apply: () => Array; }; export type RequestParam = Omit & { body?: string | Binary }; @@ -169,7 +169,7 @@ export default class Registrar { this.settingTab = new SettingTab(plugin, this.settingRegistry); plugin.addSettingTab(this.settingTab); }; - private readonly rerenderSettingTab = () => this.settingTab?.display(); + private readonly rerenderSettingTab = () => this.settingTab?.update(); root = { addSettingTab: this.addSettingTab, @@ -219,11 +219,11 @@ class SettingTab extends PluginSettingTab { this.icon = 'cpu'; } - display(): void { + getSettingDefinitions() { this.containerEl.empty(); - const sorted: Record void> = {}; + const sorted: Record Array> = {}; for (const { priority, apply } of this.settingRegistry) sorted[priority] = apply; - for (const render of Object.values(sorted)) render(this.containerEl); + return Object.values(sorted).flatMap((render) => render()); } } diff --git a/packages/plugin/src/settings/controls.ts b/packages/plugin/src/settings/controls.ts index b7aa6a06..75be4c4e 100644 --- a/packages/plugin/src/settings/controls.ts +++ b/packages/plugin/src/settings/controls.ts @@ -1,7 +1,7 @@ import type { Settings } from '@'; -import { Setting } from 'obsidian'; +import type { SettingDefinitionItem } from 'obsidian'; import type { Translate } from '@/modules/I18n'; -import { generateSettingEntry } from './generate-entry'; +import { heading, renderTogglableValue } from './utils'; export type ControlsSettingTranslations = { controls: string; @@ -20,62 +20,64 @@ export type ControlsSettingTranslations = { invalidValue: string; }; -export default function controlsSettings( - el: HTMLElement, - ctx: { - translate: Translate; - saveSettings: () => Promise; - settings: Settings; - }, -) { - const { translate, saveSettings, settings } = ctx; +export default function controlsSettings({ + translate, + saveSettings, + settings, +}: { + translate: Translate; + saveSettings: () => Promise; + settings: Settings; +}): Array { const invalidValue = translate('invalidValue'); - new Setting(el).setName(translate('controls')).setHeading(); - - generateSettingEntry({ - container: el, - desc: translate('maxFileSizeDescription'), - field: settings.maxFileSize, - invalidValue, - name: translate('maxFileSize'), - placeholder: translate('maxFileSizePlaceholder'), - rejectZero: true, - saveSettings, - type: 'fileSize', - }); - - generateSettingEntry({ - container: el, - desc: translate('maxRequestConcurrencyDescription'), - field: settings.maxRequestConcurrency, - invalidValue, - name: translate('maxRequestConcurrency'), - placeholder: translate('maxRequestConcurrencyPlaceholder'), - rejectZero: true, - saveSettings, - type: 'number', - }); - - generateSettingEntry({ - container: el, - desc: translate('minRequestIntervalDescription'), - field: settings.minRequestInterval, - invalidValue, - name: translate('minRequestInterval'), - placeholder: translate('minRequestIntervalPlaceholder'), - saveSettings, - type: 'time', - }); - - generateSettingEntry({ - container: el, - desc: translate('maxMemoryConsumptionDescription'), - field: settings.maxMemoryConsumption, - invalidValue, - name: translate('maxMemoryConsumption'), - placeholder: translate('maxMemoryConsumptionPlaceholder'), - rejectZero: true, - saveSettings, - type: 'fileSize', - }); + return [ + heading(translate('controls')), + { + desc: translate('maxFileSizeDescription'), + name: translate('maxFileSize'), + render: renderTogglableValue({ + field: settings.maxFileSize, + invalidValue, + placeholder: translate('maxFileSizePlaceholder'), + rejectZero: true, + saveSettings, + type: 'fileSize', + }), + }, + { + desc: translate('maxRequestConcurrencyDescription'), + name: translate('maxRequestConcurrency'), + render: renderTogglableValue({ + field: settings.maxRequestConcurrency, + invalidValue, + placeholder: translate('maxRequestConcurrencyPlaceholder'), + rejectZero: true, + saveSettings, + type: 'number', + }), + }, + { + desc: translate('minRequestIntervalDescription'), + name: translate('minRequestInterval'), + render: renderTogglableValue({ + field: settings.minRequestInterval, + invalidValue, + placeholder: translate('minRequestIntervalPlaceholder'), + saveSettings, + type: 'time', + }), + }, + { + desc: translate('maxMemoryConsumptionDescription'), + name: translate('maxMemoryConsumption'), + render: renderTogglableValue({ + field: settings.maxMemoryConsumption, + invalidValue, + placeholder: translate('maxMemoryConsumptionPlaceholder'), + rejectZero: true, + saveSettings, + type: 'fileSize', + }), + }, + ]; } diff --git a/packages/plugin/src/settings/development.ts b/packages/plugin/src/settings/development.ts index f201bc6e..bd1b36de 100644 --- a/packages/plugin/src/settings/development.ts +++ b/packages/plugin/src/settings/development.ts @@ -1,8 +1,12 @@ import type { Settings } from '@'; +import type { App, SettingDefinitionItem } from 'obsidian'; import { normalizeBaseDir } from '@repo/shared/path'; -import { Notice, Setting } from 'obsidian'; +import { Notice } from 'obsidian'; +import type { SourceEditorTranslations } from '@/components/SourceEditorModal'; import type { Translate } from '@/modules/I18n'; import type { MaybePromise } from '@/sdk'; +import ModuleSourceEditorModal from '@/components/SourceEditorModal'; +import { heading } from './utils'; export type DevelopmentSettingTranslations = { development: string; @@ -14,51 +18,92 @@ export type DevelopmentSettingTranslations = { exportLogsDescription: string; exportLogsDirectoryPlaceholder: string; exportLogsToFile: string; -}; + moduleSources: string; + moduleSourcesDescription: string; + edit: string; + noSourceConfigured: string; +} & SourceEditorTranslations; -export default function developmentSettings( - el: HTMLElement, - ctx: { - translate: Translate; - deleteRecordStore: (namespace?: string) => MaybePromise; - exportLogs: () => Promise; - settings: Settings; - saveSettings: () => Promise; - }, -) { - const { translate, exportLogs, deleteRecordStore, settings, saveSettings } = ctx; - new Setting(el).setName(translate('development')).setHeading(); - - new Setting(el) - .setName(translate('clearRecords')) - .setDesc(translate('clearRecordsDescription')) - .addButton((button) => - button - .setButtonText(translate('clearRecords')) - .setWarning() - .onClick(async () => { - await deleteRecordStore(); - new Notice(translate('recordsCleared')); - }), - ); - - new Setting(el) - .setName(translate('exportLogsToFile')) - .setDesc(translate('exportLogsDescription')) - .addText((text) => - text - .setValue(settings.exportLogsDirectory) - .setPlaceholder(translate('exportLogsDirectoryPlaceholder')) - .inputEl.addEventListener('blur', () => { - const normalized = normalizeBaseDir(text.getValue().trim()); - if (settings.exportLogsDirectory !== normalized) { - settings.exportLogsDirectory = normalized; - void saveSettings(); - } - text.setValue(normalized); - }), - ) - .addButton((button) => { - button.setButtonText(translate('export')).onClick(exportLogs); - }); +export default function developmentSettings({ + translate, + exportLogs, + deleteRecordStore, + settings, + saveSettings, + app, +}: { + translate: Translate; + deleteRecordStore: (namespace?: string) => MaybePromise; + exportLogs: () => Promise; + settings: Settings; + saveSettings: () => Promise; + app: App; +}): Array { + return [ + heading(translate('development')), + { + desc: translate('clearRecordsDescription'), + name: translate('clearRecords'), + render: (setting) => { + setting.addButton((button) => + button + .setButtonText(translate('clearRecords')) + .setDestructive() + .onClick(async () => { + await deleteRecordStore(); + new Notice(translate('recordsCleared')); + }), + ); + }, + }, + { + desc: translate('exportLogsDescription'), + name: translate('exportLogsToFile'), + render: (setting) => { + setting + .addText((text) => + text + .setValue(settings.exportLogsDirectory) + .setPlaceholder(translate('exportLogsDirectoryPlaceholder')) + .inputEl.addEventListener('blur', () => { + const normalized = normalizeBaseDir(text.getValue().trim()); + if (settings.exportLogsDirectory !== normalized) { + settings.exportLogsDirectory = normalized; + void saveSettings(); + } + text.setValue(normalized); + }), + ) + .addButton((button) => { + button.setButtonText(translate('export')).onClick(exportLogs); + }); + }, + }, + { + desc: translate('moduleSourcesDescription'), + emptyState: translate('noSourceConfigured'), + items: settings.moduleSources.map((source) => ({ + name: source, + })), + name: translate('moduleSources'), + type: 'list', + }, + ]; } + +/* +Render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => + new ModuleSourceEditorModal( + (sources) => { + settings.moduleSources = sources; + void saveSettings(); + }, + { app, translate }, + settings.moduleSources, + ).open(), + ); + }); + }, +*/ diff --git a/packages/plugin/src/settings/features.ts b/packages/plugin/src/settings/features.ts index 0408e060..5468a3ad 100644 --- a/packages/plugin/src/settings/features.ts +++ b/packages/plugin/src/settings/features.ts @@ -1,10 +1,10 @@ import type { Settings, Context } from '@'; -import { Setting } from 'obsidian'; +import type { SettingDefinitionItem } from 'obsidian'; import type { MigrationModalTranslations } from '@/components/MigrationModal'; import type { Fragment, Translate } from '@/modules/I18n'; import type { MaybePromise } from '@/sdk'; import setNeedMigration from '@/components/MigrationModal'; -import { generateSettingEntry } from './generate-entry'; +import { heading, renderTogglableValue } from './utils'; export type FeaturesSettingTranslations = { features: string; @@ -25,17 +25,14 @@ export type FeaturesSettingTranslations = { invalidValue: string; } & MigrationModalTranslations; -export default function featuresSettings( - el: HTMLElement, - ctx: { - translate: Translate; - saveSettings: () => Promise; - startScheduledSync: () => void; - stopScheduledSync: () => void; - settings: Settings; - recordStoreExists: () => MaybePromise; - }, -) { +export default function featuresSettings(ctx: { + translate: Translate; + saveSettings: () => Promise; + startScheduledSync: () => void; + stopScheduledSync: () => void; + settings: Settings; + recordStoreExists: () => MaybePromise; +}): Array { const { translate, saveSettings, @@ -45,73 +42,72 @@ export default function featuresSettings( recordStoreExists, } = ctx; const invalidValue = translate('invalidValue'); - new Setting(el).setName(translate('features')).setHeading(); - - generateSettingEntry({ - container: el, - desc: translate('realtimeSyncDescription'), - field: settings.realtimeSync, - invalidValue, - name: translate('realtimeSync'), - placeholder: translate('realtimeSyncPlaceholder'), - saveSettings, - type: 'time', - }); - - generateSettingEntry({ - container: el, - desc: translate('startupSyncDescription'), - field: settings.startupSync, - invalidValue, - name: translate('startupSync'), - placeholder: translate('startupSyncPlaceholder'), - saveSettings, - type: 'time', - }); - - generateSettingEntry({ - container: el, - desc: translate('scheduledSyncDescription'), - field: settings.scheduledSync, - invalidValue, - name: translate('scheduledSync'), - onChange: () => { - stopScheduledSync(); - startScheduledSync(); - }, - onToggle: (enabled) => { - if (enabled) startScheduledSync(); - else stopScheduledSync(); + return [ + heading(translate('features')), + { + desc: translate('realtimeSyncDescription'), + name: translate('realtimeSync'), + render: renderTogglableValue({ + field: settings.realtimeSync, + invalidValue, + placeholder: translate('realtimeSyncPlaceholder'), + saveSettings, + type: 'time', + }), }, - placeholder: translate('scheduledSyncPlaceholder'), - rejectZero: true, - saveSettings, - type: 'time', - }); - - new Setting(el) - .setName(translate('realtimeSyncFastMode')) - .setDesc(translate('realtimeSyncFastModeDescription')) - .addToggle((toggle) => - toggle.setValue(settings.realtimeSyncFastMode).onChange((value) => { - settings.realtimeSyncFastMode = value; - void saveSettings(); + { + desc: translate('startupSyncDescription'), + name: translate('startupSync'), + render: renderTogglableValue({ + field: settings.startupSync, + invalidValue, + placeholder: translate('startupSyncPlaceholder'), + saveSettings, + type: 'time', }), - ); - - new Setting(el) - .setName(translate('asymmetricStorage')) - .setDesc(translate('asymmetricStorageDescription')) - .addToggle((toggle) => - setNeedMigration(ctx as Context, { - apply: (value) => { - settings.asymmetricStorage = value; - void saveSettings(); + }, + { + desc: translate('scheduledSyncDescription'), + name: translate('scheduledSync'), + render: renderTogglableValue({ + field: settings.scheduledSync, + invalidValue, + onChange: () => { + stopScheduledSync(); + startScheduledSync(); + }, + onToggle: (enabled) => { + if (enabled) startScheduledSync(); + else stopScheduledSync(); }, - content: (value) => - translate('asymmetricStorageMigration', value ? 'enable' : 'disable'), - needMigration: recordStoreExists, - toggle: toggle.setValue(settings.asymmetricStorage), + placeholder: translate('scheduledSyncPlaceholder'), + rejectZero: true, + saveSettings, + type: 'time', }), - ); + }, + { + control: { key: 'realtimeSyncFastMode', type: 'toggle' }, + desc: translate('realtimeSyncFastModeDescription'), + name: translate('realtimeSyncFastMode'), + }, + { + desc: translate('asymmetricStorageDescription'), + name: translate('asymmetricStorage'), + render: (setting) => { + setting.addToggle((toggle) => + setNeedMigration(ctx as Context, { + apply: (value) => { + settings.asymmetricStorage = value; + void saveSettings(); + }, + content: (value) => + translate('asymmetricStorageMigration', value ? 'enable' : 'disable'), + needMigration: recordStoreExists, + toggle: toggle.setValue(settings.asymmetricStorage), + }), + ); + }, + }, + ]; } diff --git a/packages/plugin/src/settings/filter.ts b/packages/plugin/src/settings/filter.ts index 74aba06c..83846a3a 100644 --- a/packages/plugin/src/settings/filter.ts +++ b/packages/plugin/src/settings/filter.ts @@ -1,57 +1,65 @@ import type { Settings } from '@'; -import { App, Setting } from 'obsidian'; +import type { App, SettingDefinitionItem } from 'obsidian'; import type { FilterEditorTranslations } from '@/components/FilterEditorModal'; import type { Translate } from '@/modules/I18n'; import FilterEditorModal from '@/components/FilterEditorModal'; +import { heading } from './utils'; export type FilterSettingTranslations = { filterRules: string; edit: string; } & FilterEditorTranslations; -export default function filterSettings( - el: HTMLElement, - ctx: { - translate: Translate; - saveSettings: () => Promise; - app: App; - settings: Settings; - }, -) { - const { saveSettings, translate, settings } = ctx; - new Setting(el).setName(translate('filterRules')).setHeading(); - - new Setting(el) - .setName(translate('inclusionRules')) - .setDesc(translate('inclusionRulesDescription')) - .addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new FilterEditorModal( - (filters) => { - settings.inclusionRules = filters; - void saveSettings(); - }, - 'include', - ctx, - settings.inclusionRules, - ).open(); - }); - }); - - new Setting(el) - .setName(translate('exclusionRules')) - .setDesc(translate('exclusionRulesDescription')) - .addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new FilterEditorModal( - (filters) => { - settings.exclusionRules = filters; - void saveSettings(); - }, - 'exclude', - ctx, - settings.exclusionRules, - ).open(); - }); - }); +export default function filterSettings({ + translate, + saveSettings, + app, + settings, +}: { + translate: Translate; + saveSettings: () => Promise; + app: App; + settings: Settings; +}): Array { + return [ + heading(translate('filterRules')), + { + desc: translate('inclusionRulesDescription'), + name: translate('inclusionRules'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => { + new FilterEditorModal( + (filters) => { + settings.inclusionRules = filters; + void saveSettings(); + }, + 'include', + { app, translate }, + settings.inclusionRules, + ).open(); + }); + }); + }, + }, + { + desc: translate('exclusionRulesDescription'), + name: translate('exclusionRules'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => { + new FilterEditorModal( + (filters) => { + settings.exclusionRules = filters; + void saveSettings(); + }, + 'exclude', + { app, translate }, + settings.exclusionRules, + ).open(); + }); + }); + }, + }, + ]; } diff --git a/packages/plugin/src/settings/generate-entry.ts b/packages/plugin/src/settings/generate-entry.ts deleted file mode 100644 index 8069328d..00000000 --- a/packages/plugin/src/settings/generate-entry.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Notice, Setting } from 'obsidian'; -import type { TogglableValue } from '@/types'; -import { formatFileSize, formatTime, parseFileSize, parseTime } from '@/utils/unit-converter'; - -export type InputType = 'number' | 'time' | 'fileSize'; - -const MAX_32BIT_VALUE = 2 ** 31 - 1; - -export function generateSettingEntry({ - container, - name, - desc, - placeholder, - field, - type, - saveSettings, - rejectZero, - onChange, - onToggle, - invalidValue, -}: { - container: HTMLElement; - name: string; - desc: string; - placeholder: string; - field: TogglableValue; - type: InputType; - saveSettings: () => Promise; - rejectZero?: boolean; - onChange?: (value: number) => void; - onToggle?: (value: boolean) => void; - invalidValue: string; -}) { - new Setting(container) - .setClass('sync-engine-togglable-value') - .setName(name) - .setDesc(desc) - .addText((text) => { - text.setPlaceholder(placeholder).setValue(format(field.value, type)); - text.inputEl.addEventListener('blur', () => { - const value = parse(text.inputEl.value, type); - if ( - value === undefined || - Number.isNaN(value) || - value < 0 || - value > MAX_32BIT_VALUE || - (rejectZero && value === 0) - ) { - text.inputEl.value = format(field.value, type); - new Notice(invalidValue); - return; - } - if (value !== field.value) { - field.value = value; - onChange?.(value); - void saveSettings(); - } - text.inputEl.value = format(field.value, type); - }); - }) - .addToggle((toggle) => { - toggle.setValue(field.enabled); - toggle.onChange((value) => { - if (value !== field.enabled) { - field.enabled = value; - onToggle?.(value); - void saveSettings(); - } - }); - }); -} - -function format(value: number, type: InputType): string { - switch (type) { - case 'number': { - return value.toString(); - } - case 'time': { - return formatTime(value); - } - case 'fileSize': { - return formatFileSize(value); - } - } -} - -function parse(value: string, type: InputType): number | undefined { - switch (type) { - case 'number': { - return Number.parseFloat(value); - } - case 'time': { - return parseTime(value); - } - case 'fileSize': { - return parseFileSize(value); - } - } -} diff --git a/packages/plugin/src/settings/head.ts b/packages/plugin/src/settings/head.ts index 4e68b5d9..6f2af652 100644 --- a/packages/plugin/src/settings/head.ts +++ b/packages/plugin/src/settings/head.ts @@ -1,6 +1,8 @@ -import type { Settings } from '@'; +import type { Context, Settings } from '@'; +import type { SettingDefinitionItem } from 'obsidian'; import type { DatabaseSync } from 'uni-kv'; -import { ExtraButtonComponent, Notice, Setting } from 'obsidian'; +import { ExtraButtonComponent, Notice } from 'obsidian'; +import type { ModuleCtor } from '@/modules/Extensibility'; import type { Translate } from '@/modules/I18n'; import type { CheckConnectionResult, @@ -10,6 +12,7 @@ import type { } from '@/modules/Registrar'; import type { General, MaybePromise } from '@/types'; import toErrorMessage from '@/utils/to-error-message'; +import ModuleManagement from './module-management'; const CHECK_CONNECTION_INTERVAL = 10_000; @@ -18,7 +21,6 @@ export type HeadSettingTranslations = { moduleAutoUpdateDescription: string; moduleManagement: string; moduleManagementDescription: string; - openPanel: string; backend: string; backendDescription: string; syncStrategy: string; @@ -28,57 +30,145 @@ export type HeadSettingTranslations = { checkConnection: string; conflictResolveStrategy: string; conflictResolveStrategyDescription: string; + xEnabled: string; }; -export default function headSettings( - el: HTMLElement, - ctx: { - translate: Translate; - saveSettings: () => Promise; - settings: Settings; - openModuleManagement: () => void; - remoteFsRegistry: Map; - deciderRegistry: Map; - conflictResolverRegistry: Map; - getCheckConnection: () => () => MaybePromise; - memoryDB: DatabaseSync; - }, -) { +type CheckConnectionDB = DatabaseSync; + +export default function headSettings(ctx: { + translate: Translate; + saveSettings: () => Promise; + settings: Settings; + remoteFsRegistry: Map; + deciderRegistry: Map; + conflictResolverRegistry: Map; + getCheckConnection: () => () => MaybePromise; + memoryDB: CheckConnectionDB; + loadedModules: Map; +}): Array { const { + loadedModules, translate, saveSettings, settings, - openModuleManagement, remoteFsRegistry, deciderRegistry, getCheckConnection, memoryDB, conflictResolverRegistry, } = ctx; + return [ + { + desc: translate('backendDescription'), + name: translate('backend'), + render: (setting) => { + let checkConnection: (force?: boolean) => Promise; + let cleanup!: () => void; + setting + .addExtraButton((button) => { + const checks = setupCheckConnection({ + button: button + .setTooltip(translate('checkConnection')) + .onClick(() => void checkConnection(true)), + getCheckConnection, + memoryDB, + settings, + translate, + }); + checkConnection = checks.checkConnection; + cleanup = checks.cleanup; + void checkConnection(false); + }) + .addDropdown((dropdown) => { + for (const [key, { prettyName }] of remoteFsRegistry) + dropdown.addOption(key, prettyName()); + dropdown.setValue(settings.remoteFs).onChange((value) => { + settings.remoteFs = value; + void checkConnection(); + void saveSettings(); + }); + }); + return cleanup; + }, + }, + { + desc: translate('moduleManagementDescription'), + displayValue: translate('xEnabled', { x: loadedModules.size }), + name: translate('moduleManagement'), + page: () => new ModuleManagement(ctx as Context), + type: 'page', + }, + { + control: { + key: 'moduleAutoUpdate', + type: 'toggle', + }, + desc: translate('moduleAutoUpdateDescription'), + name: translate('moduleAutoUpdate'), + }, + { + control: { + key: 'decider', + options: Object.fromEntries( + [...deciderRegistry].map(([key, { prettyName }]) => [key, prettyName()]), + ), + type: 'dropdown', + }, + desc: translate('syncStrategyDescription'), + name: translate('syncStrategy'), + }, + { + control: { + key: 'conflictResolver', + options: Object.fromEntries( + [...conflictResolverRegistry].map(([key, { prettyName }]) => [ + key, + prettyName(), + ]), + ), + type: 'dropdown', + }, + desc: translate('conflictResolveStrategyDescription'), + name: translate('conflictResolveStrategy'), + }, + ]; +} - let statusButton: ExtraButtonComponent | undefined; - +function setupCheckConnection({ + memoryDB, + getCheckConnection, + settings, + translate, + button, +}: { + memoryDB: CheckConnectionDB; + getCheckConnection: () => () => MaybePromise; + settings: Settings; + translate: Translate; + button: ExtraButtonComponent; +}) { + let timeout: number | undefined; const possibleClasses = [ 'color-[--color-green]', 'color-[--color-red]', 'color-neutral-600', 'animate-spin', ]; - const setChecking = (button: ExtraButtonComponent) => { + const setChecking = () => { button.setIcon('loader-circle'); const ele = button.extraSettingsEl.firstElementChild; if (!ele) return; ele.removeClasses(possibleClasses); ele.addClasses(['animate-spin', 'color-neutral-600']); }; - const setSuccess = (button: ExtraButtonComponent) => { + const setSuccess = () => { button.setIcon('check'); const ele = button.extraSettingsEl.firstElementChild; if (!ele) return; ele.removeClasses(possibleClasses); ele.addClasses(['color-[--color-green]']); }; - const setError = (button: ExtraButtonComponent) => { + const setError = () => { button.setIcon('cloud-off'); const ele = button.extraSettingsEl.firstElementChild; if (!ele) return; @@ -86,100 +176,37 @@ export default function headSettings( ele.addClasses(['color-[--color-red]']); }; const scheduleCheckConnection = () => - window.setTimeout(() => void checkConnection(), CHECK_CONNECTION_INTERVAL); + (timeout = window.setTimeout(() => void checkConnection(), CHECK_CONNECTION_INTERVAL)); - const checkConnection = async (force = false, skipGC = false) => { - if (!statusButton) return; - if (!statusButton.extraSettingsEl.isConnected && !skipGC) { - statusButton = undefined; - return; - } + const checkConnection = async (force = false) => { if (memoryDB.getMeta('lastCheckedFs') === settings.remoteFs && !force) { - setSuccess(statusButton); + setSuccess(); return; } if (!settings.remoteFs) { - setError(statusButton); + setError(); return; } try { - setChecking(statusButton); + setChecking(); const result = await getCheckConnection()(); if (result.success) { memoryDB.setMeta('lastCheckedFs', settings.remoteFs); - setSuccess(statusButton); + setSuccess(); if (force) new Notice(translate('checkConnectionSuccess')); } else { - setError(statusButton); + setError(); if (force) new Notice(`${translate('checkConnectionFailed')}: ${result.reason}`); else scheduleCheckConnection(); } } catch (error) { - setError(statusButton); + setError(); if (force) new Notice(`${translate('checkConnectionFailed')}: ${toErrorMessage(error)}`); else scheduleCheckConnection(); } }; - new Setting(el) - .setName(translate('backend')) - .setDesc(translate('backendDescription')) - .addExtraButton((button) => { - statusButton = button - .setTooltip(translate('checkConnection')) - .onClick(() => void checkConnection(true)); - }) - .addDropdown((dropdown) => { - for (const [key, { prettyName }] of remoteFsRegistry) - dropdown.addOption(key, prettyName()); - dropdown.setValue(settings.remoteFs).onChange((value) => { - settings.remoteFs = value; - void checkConnection(); - void saveSettings(); - }); - }); - void checkConnection(false, true); - - new Setting(el) - .setName(translate('moduleManagement')) - .setDesc(translate('moduleManagementDescription')) - .addButton((button) => - button.setButtonText(translate('openPanel')).onClick(openModuleManagement).setCta(), - ); - - new Setting(el) - .setName(translate('moduleAutoUpdate')) - .setDesc(translate('moduleAutoUpdateDescription')) - .addToggle((toggle) => - toggle.setValue(settings.moduleAutoUpdate).onChange((value) => { - settings.moduleAutoUpdate = value; - void saveSettings(); - }), - ); - - new Setting(el) - .setName(translate('syncStrategy')) - .setDesc(translate('syncStrategyDescription')) - .addDropdown((dropdown) => { - for (const [key, { prettyName }] of deciderRegistry) - dropdown.addOption(key, prettyName()); - dropdown.setValue(settings.decider).onChange((value) => { - settings.decider = value; - void saveSettings(); - }); - }); - - new Setting(el) - .setName(translate('conflictResolveStrategy')) - .setDesc(translate('conflictResolveStrategyDescription')) - .addDropdown((dropdown) => { - for (const [key, { prettyName }] of conflictResolverRegistry) - dropdown.addOption(key, prettyName()); - dropdown.setValue(settings.conflictResolver).onChange((value) => { - settings.conflictResolver = value; - void saveSettings(); - }); - }); + return { checkConnection, cleanup: () => window.clearTimeout(timeout) }; } diff --git a/packages/plugin/src/settings/miscellaneous.ts b/packages/plugin/src/settings/miscellaneous.ts index e650a1e0..14741140 100644 --- a/packages/plugin/src/settings/miscellaneous.ts +++ b/packages/plugin/src/settings/miscellaneous.ts @@ -1,7 +1,8 @@ -import type { Context, Settings } from '@'; -import { Setting } from 'obsidian'; +import type { Settings } from '@'; +import type { App, SettingDefinitionItem } from 'obsidian'; import type { Translate } from '@/modules/I18n'; import HeadersEditorModal from '@/components/HeadersEditorModal'; +import { heading } from './utils'; export type MiscellaneousSettingTranslations = { miscellaneous: string; @@ -20,62 +21,51 @@ export type MiscellaneousSettingTranslations = { edit: string; }; -export default function miscellaneousSettings( - el: HTMLElement, - ctx: { - translate: Translate; - saveSettings: () => Promise; - settings: Settings; - }, -) { - const { translate, saveSettings, settings } = ctx; - - new Setting(el).setName(translate('miscellaneous')).setHeading(); - - new Setting(el) - .setName(translate('customHeaders')) - .setDesc(translate('customHeadersDescription')) - .addButton((button) => { - button.setButtonText(translate('edit')); - button.onClick(() => { - new HeadersEditorModal( - (headers) => { - settings.customHeaders = headers; - void saveSettings(); - }, - ctx as Context, - settings.customHeaders, - ).open(); - }); - }); - - new Setting(el) - .setName(translate('noticeStatusOnMobile')) - .setDesc(translate('noticeStatusOnMobileDescription')) - .addToggle((toggle) => - toggle.setValue(settings.noticeStatusOnMobile).onChange((value) => { - settings.noticeStatusOnMobile = value; - void saveSettings(); - }), - ); - - new Setting(el) - .setName(translate('confirmTasksInSync')) - .setDesc(translate('confirmTasksInSyncDescription')) - .addToggle((toggle) => - toggle.setValue(settings.confirmTasksInSync).onChange((value) => { - settings.confirmTasksInSync = value; - void saveSettings(); - }), - ); - - new Setting(el) - .setName(translate('confirmDeleteInAutoSync')) - .setDesc(translate('confirmDeleteInAutoSyncDescription')) - .addToggle((toggle) => - toggle.setValue(settings.confirmDeleteInAutoSync).onChange((value) => { - settings.confirmDeleteInAutoSync = value; - void saveSettings(); - }), - ); +export default function miscellaneousSettings({ + translate, + saveSettings, + settings, + app, +}: { + translate: Translate; + saveSettings: () => Promise; + settings: Settings; + app: App; +}): Array { + return [ + heading(translate('miscellaneous')), + { + desc: translate('customHeadersDescription'), + name: translate('customHeaders'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => { + new HeadersEditorModal( + (headers) => { + settings.customHeaders = headers; + void saveSettings(); + }, + { app, translate }, + settings.customHeaders, + ).open(); + }); + }); + }, + }, + { + control: { key: 'noticeStatusOnMobile', type: 'toggle' }, + desc: translate('noticeStatusOnMobileDescription'), + name: translate('noticeStatusOnMobile'), + }, + { + control: { key: 'confirmTasksInSync', type: 'toggle' }, + desc: translate('confirmTasksInSyncDescription'), + name: translate('confirmTasksInSync'), + }, + { + control: { key: 'confirmDeleteInAutoSync', type: 'toggle' }, + desc: translate('confirmDeleteInAutoSyncDescription'), + name: translate('confirmDeleteInAutoSync'), + }, + ]; } diff --git a/packages/plugin/src/settings/module-management.ts b/packages/plugin/src/settings/module-management.ts new file mode 100644 index 00000000..e8fc08e5 --- /dev/null +++ b/packages/plugin/src/settings/module-management.ts @@ -0,0 +1,89 @@ +import type { Settings } from '@'; +import { App, SearchComponent, setIcon, SettingPage, setTooltip } from 'obsidian'; +import { hook } from 'synthkernel'; +import type { ModuleManagementTranslations } from '@/components/module-management'; +import type { AugmentedModuleMeta } from '@/modules/Extensibility'; +import type { Translate } from '@/modules/I18n'; +import { mountModuleManagementList } from '@/components/module-management'; +import ModuleSourceEditorModal from '@/components/SourceEditorModal'; + +export type ModulesTranslations = ModuleManagementTranslations & { + searchModules: string; + moduleManagement: string; + showInstalledOnly: string; +}; + +export default class ModuleManagement extends SettingPage { + private readonly t: Translate; + private readonly cleanup: Array<() => void> = []; + private sourceEditorModal?: ModuleSourceEditorModal; + private showInstalledOnly = false; + + constructor( + private readonly ctx: { + app: App; + translate: Translate; + saveSettings: () => Promise; + fetchSources: (manual?: boolean) => Promise>; + discoveredModules: Map; + loadedModules: Map; + downloadModule: (meta: AugmentedModuleMeta) => Promise; + deleteModule: (id: string) => Promise; + loadModule: (meta: AugmentedModuleMeta, start?: boolean) => Promise; + unloadModule: (id: string) => void; + enableModule: (id: string) => Promise; + disableModule: (id: string) => void; + updateModuleMeta: (meta: AugmentedModuleMeta) => Promise; + settings: Settings; + pluginOutdated: boolean; + }, + ) { + super(); + this.title = ctx.translate('moduleManagement'); + this.t = ctx.translate; + } + + display() { + const controlsEl = this.containerEl.createDiv('flex items-center gap-2 pb-4'); + const searchEl = controlsEl.createDiv('min-w-0 flex-1'); + const listEl = this.containerEl.createDiv('min-h-0 overflow-y-auto'); + const onQuery = hook<[string]>(); + const onShowInstalledOnlyChange = hook<[boolean]>(); + + const search = new SearchComponent(searchEl) + .setPlaceholder(this.t('searchModules')) + .onChange(onQuery); + search.inputEl.addClass('w-full'); + search.inputEl.spellcheck = false; + + const menuButton = controlsEl.createEl('button', 'clickable-icon flex-shrink-0 rounded-md'); + setIcon(menuButton, 'hard-drive-download'); + setTooltip(menuButton, this.t('showInstalledOnly')); + const activeClasses = ['bg-[--interactive-accent]!', 'color-[--text-on-accent]!']; + menuButton.onClickEvent(() => { + this.showInstalledOnly = !this.showInstalledOnly; + if (this.showInstalledOnly) menuButton.addClasses(activeClasses); + else menuButton.removeClasses(activeClasses); + onShowInstalledOnlyChange(this.showInstalledOnly); + }); + + this.cleanup.push( + mountModuleManagementList(listEl, this.ctx, { + onQuery, + onShowInstalledOnlyChange, + }), + () => { + onQuery.clear(); + onShowInstalledOnlyChange.clear(); + }, + ); + onShowInstalledOnlyChange(this.showInstalledOnly); + } + + hide() { + this.sourceEditorModal?.close(); + this.sourceEditorModal = undefined; + this.cleanup.splice(0).forEach((fn) => fn()); + this.containerEl.empty(); + } +} diff --git a/packages/plugin/src/settings/utils.ts b/packages/plugin/src/settings/utils.ts new file mode 100644 index 00000000..0e635f21 --- /dev/null +++ b/packages/plugin/src/settings/utils.ts @@ -0,0 +1,109 @@ +import type { Setting, SettingDefinitionItem } from 'obsidian'; +import type { TogglableValue } from '@/types'; +import { formatFileSize, formatTime, parseFileSize, parseTime } from '@/utils/unit-converter'; + +type InputType = 'number' | 'time' | 'fileSize'; + +const MAX_32BIT_VALUE = 2 ** 31 - 1; +const WARNING_INTERVAL = 2000; + +export function heading(name: string): SettingDefinitionItem { + return { + name, + render: (setting) => { + setting.setHeading(); + }, + }; +} + +export function renderTogglableValue({ + placeholder, + field, + type, + saveSettings, + rejectZero, + onChange, + onToggle, + invalidValue, +}: { + placeholder: string; + field: TogglableValue; + type: InputType; + saveSettings: () => Promise; + rejectZero?: boolean; + onChange?: (value: number) => void; + onToggle?: (value: boolean) => void; + invalidValue: string; +}): (setting: Setting) => () => void { + return (setting) => { + let timeout: number | undefined; + setting + .setClass('sync-engine-togglable-value') + .addText((text) => { + text.setPlaceholder(placeholder).setValue(format(field.value, type)); + text.inputEl.addEventListener('blur', () => { + const value = parse(text.inputEl.value, type); + if ( + value === undefined || + Number.isNaN(value) || + value < 0 || + value > MAX_32BIT_VALUE || + (rejectZero && value === 0) + ) { + text.inputEl.value = format(field.value, type); + setting.setErrorMessage(invalidValue); + clearTimeout(timeout); + timeout = window.setTimeout(() => { + setting.setErrorMessage(''); + }, WARNING_INTERVAL); + return; + } + if (value !== field.value) { + field.value = value; + onChange?.(value); + void saveSettings(); + } + text.inputEl.value = format(field.value, type); + }); + }) + .addToggle((toggle) => { + toggle.setValue(field.enabled); + toggle.onChange((value) => { + if (value !== field.enabled) { + field.enabled = value; + onToggle?.(value); + void saveSettings(); + } + }); + }); + return () => window.clearTimeout(timeout); + }; +} + +function format(value: number, type: InputType): string { + switch (type) { + case 'number': { + return value.toString(); + } + case 'time': { + return formatTime(value); + } + case 'fileSize': { + return formatFileSize(value); + } + } +} + +function parse(value: string, type: InputType): number | undefined { + switch (type) { + case 'number': { + return Number.parseFloat(value); + } + case 'time': { + return parseTime(value); + } + case 'fileSize': { + return parseFileSize(value); + } + } +} diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts index 9d3189fc..025da520 100644 --- a/packages/s3/src/index.ts +++ b/packages/s3/src/index.ts @@ -148,7 +148,7 @@ export default class S3 { priority: 303, }), registerSetting({ - apply: (el) => s3Setting(el, this.ctx, this.moduleSettings), + apply: () => s3Setting(this.ctx, this.moduleSettings), priority: 604, }), ); diff --git a/packages/s3/src/setting.ts b/packages/s3/src/setting.ts index 53fd242b..8628d6b2 100644 --- a/packages/s3/src/setting.ts +++ b/packages/s3/src/setting.ts @@ -1,7 +1,8 @@ import type { S3Settings } from '@'; import type { Fragment, Translate, Translations } from '@hesprs/sync-engine-sdk'; +import type { App, SettingDefinitionItem } from 'obsidian'; import { normalizeBaseDir, normalizeUrl } from '@repo/shared/path'; -import { App, Notice, SecretComponent, Setting } from 'obsidian'; +import { Notice, SecretComponent } from 'obsidian'; import type { UrlStyle } from './s3/sigv4'; import handleInput from './handle-input'; @@ -34,146 +35,176 @@ export type S3Translations = { }; export default function s3Setting( - el: HTMLElement, - ctx: { + { + translate, + saveSettings, + app, + }: { translate: Translate; saveSettings: () => Promise; app: App; }, settings: S3Settings, -) { - const { translate, saveSettings, app } = ctx; +): Array { const invalidValue = translate('invalidValue'); - new Setting(el).setName(translate('s3')).setHeading(); - - new Setting(el) - .setName(translate('endpoint')) - .setDesc(translate('endpointDescription')) - .addText((text) => { - text.setPlaceholder(translate('endpointPlaceholder')).setValue(settings.endpoint); - handleInput({ - invalidValue, - key: 'endpoint', - processValue: (value) => { - try { - return normalizeUrl(value); - } catch { - return false; - } - }, - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('region')) - .setDesc(translate('regionDescription')) - .addText((text) => { - text.setPlaceholder(translate('regionPlaceholder')).setValue(settings.region); - handleInput({ - invalidValue, - key: 'region', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('accessKeyId')) - .setDesc(translate('accessKeyIdDescription')) - .addText((text) => { - text.setPlaceholder(translate('accessKeyIdPlaceholder')).setValue(settings.accessKeyId); - handleInput({ - invalidValue, - key: 'accessKeyId', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('secretAccessKey')) - .setDesc(translate('secretAccessKeyDescription')) - .addComponent((element) => - new SecretComponent(app, element) - .setValue(settings.secretAccessKey) - .onChange((value) => { - settings.secretAccessKey = value; - void saveSettings(); - }), - ); - - new Setting(el) - .setName(translate('bucket')) - .setDesc(translate('bucketDescription')) - .addText((text) => { - text.setPlaceholder(translate('bucketPlaceholder')).setValue(settings.bucket); - handleInput({ - invalidValue, - key: 'bucket', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('urlStyle')) - .setDesc(translate('urlStyleDescription')) - .addDropdown((dropdown) => { - dropdown - .addOption('virtualHosted', translate('urlStyleVirtualHosted')) - .addOption('path', translate('urlStylePath')) - .setValue(settings.urlStyle) - .onChange((value) => { - settings.urlStyle = value as UrlStyle; - void saveSettings(); + return [ + { + name: translate('s3'), + render: (setting) => { + setting.setHeading(); + }, + }, + { + desc: translate('endpointDescription'), + name: translate('endpoint'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('endpointPlaceholder')).setValue( + settings.endpoint, + ); + handleInput({ + invalidValue, + key: 'endpoint', + processValue: (value) => { + try { + return normalizeUrl(value); + } catch { + return false; + } + }, + saveSettings, + settings, + text, + }); }); - }); - - new Setting(el) - .setName(translate('prefix')) - .setDesc(translate('prefixDescription')) - .addText((text) => { - text.setPlaceholder(translate('prefixPlaceholder')).setValue(settings.prefix); - handleInput({ - invalidValue, - key: 'prefix', - processValue: (original) => normalizeBaseDir(original.trim()), - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('proxyUrl')) - .setDesc(translate('proxyUrlDescription')) - .addText((text) => { - text.setPlaceholder(translate('proxyUrlPlaceholder')) - .setValue(settings.proxyUrl.value) - .inputEl.addEventListener('blur', () => { - const original = settings.proxyUrl.value; - try { - settings.proxyUrl.value = normalizeUrl(text.getValue()); - } catch { - new Notice(translate('invalidValue')); - settings.proxyUrl.value = original; - } - text.setValue(settings.proxyUrl.value); + }, + }, + { + desc: translate('regionDescription'), + name: translate('region'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('regionPlaceholder')).setValue(settings.region); + handleInput({ + invalidValue, + key: 'region', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + }, + { + desc: translate('accessKeyIdDescription'), + name: translate('accessKeyId'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('accessKeyIdPlaceholder')).setValue( + settings.accessKeyId, + ); + handleInput({ + invalidValue, + key: 'accessKeyId', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + }, + { + desc: translate('secretAccessKeyDescription'), + name: translate('secretAccessKey'), + render: (setting) => { + setting.addComponent((element) => + new SecretComponent(app, element) + .setValue(settings.secretAccessKey) + .onChange((value) => { + settings.secretAccessKey = value; + void saveSettings(); + }), + ); + }, + }, + { + desc: translate('bucketDescription'), + name: translate('bucket'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('bucketPlaceholder')).setValue(settings.bucket); + handleInput({ + invalidValue, + key: 'bucket', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + }, + { + desc: translate('urlStyleDescription'), + name: translate('urlStyle'), + render: (setting) => { + setting.addDropdown((dropdown) => + dropdown + .addOption('virtualHosted', translate('urlStyleVirtualHosted')) + .addOption('path', translate('urlStylePath')) + .setValue(settings.urlStyle) + .onChange((value) => { + settings.urlStyle = value as UrlStyle; + void saveSettings(); + }), + ); + }, + }, + { + desc: translate('prefixDescription'), + name: translate('prefix'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('prefixPlaceholder')).setValue(settings.prefix); + handleInput({ + invalidValue, + key: 'prefix', + processValue: (original) => normalizeBaseDir(original.trim()), + saveSettings, + settings, + text, + }); }); - }) - .addToggle((toggle) => - toggle.setValue(settings.proxyUrl.enabled).onChange((value) => { - settings.proxyUrl.enabled = value; - void saveSettings(); - }), - ); + }, + }, + { + desc: translate('proxyUrlDescription'), + name: translate('proxyUrl'), + render: (setting) => { + setting + .addText((text) => { + text.setPlaceholder(translate('proxyUrlPlaceholder')) + .setValue(settings.proxyUrl.value) + .inputEl.addEventListener('blur', () => { + const original = settings.proxyUrl.value; + try { + settings.proxyUrl.value = normalizeUrl(text.getValue()); + } catch { + new Notice(translate('invalidValue')); + settings.proxyUrl.value = original; + } + text.setValue(settings.proxyUrl.value); + }); + }) + .addToggle((toggle) => + toggle.setValue(settings.proxyUrl.enabled).onChange((value) => { + settings.proxyUrl.enabled = value; + void saveSettings(); + }), + ); + }, + }, + ]; } diff --git a/packages/smart-merge/src/index.ts b/packages/smart-merge/src/index.ts index 3b7580c6..e6c18ee3 100644 --- a/packages/smart-merge/src/index.ts +++ b/packages/smart-merge/src/index.ts @@ -77,8 +77,7 @@ export default class SmartMerge { resolver: smartMergeResolver(this.moduleSettings, indexedDB, getNamespace), }), registerSetting({ - apply: (el) => - smartMergeSetting(el, { saveSettings, translate }, this.moduleSettings), + apply: () => smartMergeSetting({ saveSettings, translate }, this.moduleSettings), priority: 4048, }), ); diff --git a/packages/smart-merge/src/setting.ts b/packages/smart-merge/src/setting.ts index b012c01b..c3f8b8d0 100644 --- a/packages/smart-merge/src/setting.ts +++ b/packages/smart-merge/src/setting.ts @@ -1,76 +1,59 @@ import type { Translate } from '@hesprs/sync-engine-sdk'; -import { Setting } from 'obsidian'; +import type { SettingDefinitionItem, TextComponent } from 'obsidian'; import type { SmartMergeTranslations } from './i18n'; import type { MergeOptions } from './utils/merge'; export type SmartMergeSettings = MergeOptions; export default function smartMergeSetting( - el: HTMLElement, - ctx: { translate: Translate; saveSettings: () => Promise }, + { + translate, + saveSettings, + }: { translate: Translate; saveSettings: () => Promise }, settings: SmartMergeSettings, -) { - const { translate, saveSettings } = ctx; - - new Setting(el).setName(translate('smartMerge')).setHeading(); - - new Setting(el) - .setName(translate('conflictOursMarkers')) - .setDesc(translate('conflictOursMarkersDescription')) - .addText((text) => { - text.setValue(settings.conflictAStart) - .setPlaceholder(translate('start')) - .onChange((value) => { - settings.conflictAStart = value; - void saveSettings(); - }); - }) - .addText((text) => { - text.setValue(settings.conflictAEnd) - .setPlaceholder(translate('end')) - .onChange((value) => { - settings.conflictAEnd = value; - void saveSettings(); - }); - }); - - new Setting(el) - .setName(translate('conflictTheirsMarkers')) - .setDesc(translate('conflictTheirsMarkersDescription')) - .addText((text) => { - text.setValue(settings.conflictBStart) - .setPlaceholder(translate('start')) - .onChange((value) => { - settings.conflictBStart = value; - void saveSettings(); - }); - }) - .addText((text) => { - text.setValue(settings.conflictBEnd) - .setPlaceholder(translate('end')) - .onChange((value) => { - settings.conflictBEnd = value; - void saveSettings(); - }); - }); - - new Setting(el) - .setName(translate('deletionMarkers')) - .setDesc(translate('deletionMarkersDescription')) - .addText((text) => { - text.setValue(settings.deletionStart) - .setPlaceholder(translate('start')) - .onChange((value) => { - settings.deletionStart = value; - void saveSettings(); - }); - }) - .addText((text) => { - text.setValue(settings.deletionEnd) - .setPlaceholder(translate('end')) - .onChange((value) => { - settings.deletionEnd = value; +): Array { + const marker = + (key: keyof SmartMergeSettings, placeholder: string) => (text: TextComponent) => { + text.setValue(settings[key]) + .setPlaceholder(placeholder) + .onChange((value: string) => { + settings[key] = value; void saveSettings(); }); - }); + }; + return [ + { + name: translate('smartMerge'), + render: (setting) => { + setting.setHeading(); + }, + }, + { + desc: translate('conflictOursMarkersDescription'), + name: translate('conflictOursMarkers'), + render: (setting) => { + setting + .addText(marker('conflictAStart', translate('start'))) + .addText(marker('conflictAEnd', translate('end'))); + }, + }, + { + desc: translate('conflictTheirsMarkersDescription'), + name: translate('conflictTheirsMarkers'), + render: (setting) => { + setting + .addText(marker('conflictBStart', translate('start'))) + .addText(marker('conflictBEnd', translate('end'))); + }, + }, + { + desc: translate('deletionMarkersDescription'), + name: translate('deletionMarkers'), + render: (setting) => { + setting + .addText(marker('deletionStart', translate('start'))) + .addText(marker('deletionEnd', translate('end'))); + }, + }, + ]; } diff --git a/packages/webdav/src/index.ts b/packages/webdav/src/index.ts index 2af82120..faf43f28 100644 --- a/packages/webdav/src/index.ts +++ b/packages/webdav/src/index.ts @@ -5,7 +5,6 @@ import type { Translations, SelectFromContext, SettingEntry, - Context, ObsidianLanguageCode, TranslationResource, Settings, @@ -38,6 +37,7 @@ export default class Webdav { registerRemoteFsWrapper: (entry: FsWrapperEntry) => () => void; registerSetting: (entry: SettingEntry) => () => void; registerI18n: (lang: ObsidianLanguageCode, translations: TranslationResource) => void; + saveSettings: () => Promise; }>, ) { if (!this.moduleSettings.baseDirectory) @@ -96,7 +96,7 @@ export default class Webdav { priority: 6318, }), registerSetting({ - apply: (el) => webdavSetting(el, this.ctx as Context, this.moduleSettings), + apply: () => webdavSetting(this.ctx, this.moduleSettings), priority: 749, }), ); diff --git a/packages/webdav/src/setting.ts b/packages/webdav/src/setting.ts index 82a3e8b3..46eec65f 100644 --- a/packages/webdav/src/setting.ts +++ b/packages/webdav/src/setting.ts @@ -1,7 +1,8 @@ import type { WebdavSettings } from '@'; import type { Translate, Translations } from '@hesprs/sync-engine-sdk'; +import type { App, SettingDefinitionItem } from 'obsidian'; import { normalizeBaseDir, normalizeUrl } from '@repo/shared/path'; -import { App, SecretComponent, Setting } from 'obsidian'; +import { SecretComponent } from 'obsidian'; import handleInput from './handle-input'; export type WebdavTranslations = { @@ -24,98 +25,125 @@ export type WebdavTranslations = { }; export default function webdavSetting( - el: HTMLElement, - ctx: { + { + translate, + saveSettings, + app, + }: { translate: Translate; saveSettings: () => Promise; app: App; }, settings: WebdavSettings, -) { - const { translate, saveSettings, app } = ctx; +): Array { const invalidValue = translate('invalidValue'); - new Setting(el).setName(translate('webdav')).setHeading(); - - new Setting(el) - .setName(translate('endpoint')) - .setDesc(translate('endpointDescription')) - .addText((text) => { - text.setPlaceholder(translate('endpointPlaceholder')).setValue(settings.endpoint); - handleInput({ - invalidValue, - key: 'endpoint', - processValue: (value) => { - try { - return normalizeUrl(value); - } catch { - return false; - } - }, - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('username')) - .setDesc(translate('usernameDescription')) - .addText((text) => { - text.setPlaceholder(translate('usernamePlaceholder')).setValue(settings.username); - handleInput({ - invalidValue, - key: 'username', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('password')) - .setDesc(translate('passwordDescription')) - .addComponent((element) => - new SecretComponent(app, element).setValue(settings.password).onChange((password) => { - settings.password = password; - void saveSettings(); - }), - ); - - new Setting(el) - .setName(translate('baseDirectory')) - .setDesc(translate('baseDirectoryDescription')) - .addText((text) => { - text.setPlaceholder(translate('baseDirectoryPlaceholder')).setValue( - settings.baseDirectory, - ); - handleInput({ - invalidValue, - key: 'baseDirectory', - processValue: (original) => normalizeBaseDir(original.trim()), - saveSettings, - settings, - text, - }); - }); - - new Setting(el) - .setName(translate('depthInfinity')) - .setDesc(translate('depthInfinityDescription')) - .addToggle((toggle) => { - toggle.setValue(settings.depthInfinity).onChange((value) => { - settings.depthInfinity = value; - void saveSettings(); - }); - }); - - new Setting(el) - .setName(translate('chunkedUpload')) - .setDesc(translate('chunkedUploadDescription')) - .addToggle((toggle) => { - toggle.setValue(settings.chunkedUpload).onChange((value) => { - settings.chunkedUpload = value; - void saveSettings(); - }); - }); + return [ + { + name: translate('webdav'), + render: (setting) => { + setting.setHeading(); + }, + }, + { + desc: translate('endpointDescription'), + name: translate('endpoint'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('endpointPlaceholder')).setValue( + settings.endpoint, + ); + handleInput({ + invalidValue, + key: 'endpoint', + processValue: (value) => { + try { + return normalizeUrl(value); + } catch { + return false; + } + }, + saveSettings, + settings, + text, + }); + }); + }, + }, + { + desc: translate('usernameDescription'), + name: translate('username'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('usernamePlaceholder')).setValue( + settings.username, + ); + handleInput({ + invalidValue, + key: 'username', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + }, + { + desc: translate('passwordDescription'), + name: translate('password'), + render: (setting) => { + setting.addComponent((element) => + new SecretComponent(app, element) + .setValue(settings.password) + .onChange((password) => { + settings.password = password; + void saveSettings(); + }), + ); + }, + }, + { + desc: translate('baseDirectoryDescription'), + name: translate('baseDirectory'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('baseDirectoryPlaceholder')).setValue( + settings.baseDirectory, + ); + handleInput({ + invalidValue, + key: 'baseDirectory', + processValue: (original) => normalizeBaseDir(original.trim()), + saveSettings, + settings, + text, + }); + }); + }, + }, + { + desc: translate('depthInfinityDescription'), + name: translate('depthInfinity'), + render: (setting) => { + setting.addToggle((toggle) => + toggle.setValue(settings.depthInfinity).onChange((value) => { + settings.depthInfinity = value; + void saveSettings(); + }), + ); + }, + }, + { + desc: translate('chunkedUploadDescription'), + name: translate('chunkedUpload'), + render: (setting) => { + setting.addToggle((toggle) => + toggle.setValue(settings.chunkedUpload).onChange((value) => { + settings.chunkedUpload = value; + void saveSettings(); + }), + ); + }, + }, + ]; } diff --git a/scripts/deploy-modules.ts b/scripts/deploy-modules.ts index 8359e7d8..7bb249d3 100644 --- a/scripts/deploy-modules.ts +++ b/scripts/deploy-modules.ts @@ -61,7 +61,7 @@ async function main(): Promise { PUBLIC_ALTERNATIVE_MODULES_PATH, source.replaceAll( 'sync.consensia.cc', - 'github.com/hesprs/sync-engine/raw/refs/heads/gh-pages', + 'raw.githubusercontent.com/hesprs/sync-engine/refs/heads/gh-pages', ), ); console.log(`Wrote modules.json with ${result.length} module(s)`); diff --git a/skills/debug-module/SKILL.md b/skills/debug-module/SKILL.md index 7369bb1c..6119194b 100644 --- a/skills/debug-module/SKILL.md +++ b/skills/debug-module/SKILL.md @@ -10,6 +10,7 @@ When writing a debug module, you need to produce a plain, self-contained JS ESM - Register a request middleware to log raw request raw request and response. - Register a filesystem wrapper to trace the files. - Subscribe to Sync Engine events and (execute code to) gather information when event fires. +- Wrap and reassign a property in `Context` to intercept calls. You must: From e74faa9cc933872f8b53d65c635a0f944db6d73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?He=CC=84sperus?= Date: Tue, 18 Aug 2026 18:29:50 +0800 Subject: [PATCH 2/3] feat(setting): recursive extensible setting definition --- .github/workflows/release-plugin.yml | 1 - AGENTS.md | 2 +- packages/encryption/src/index.ts | 2 +- packages/encryption/src/setting.ts | 71 ++-- packages/i18n/src/ru/translations.ts | 7 +- packages/i18n/src/zh-TW/translations.ts | 6 +- packages/i18n/src/zh/translations.ts | 6 +- packages/plugin/dist/dev.spec.d.ts | 2 +- ...pA4.spec.d.ts => index-BvcV-epZ.spec.d.ts} | 61 ++-- packages/plugin/dist/index.spec.d.ts | 4 +- .../src/components/FilterEditorModal.ts | 1 + .../plugin/src/components/MigrationModal.ts | 5 +- packages/plugin/src/en.ts | 4 +- packages/plugin/src/global.css | 19 +- packages/plugin/src/modules/Bootstrap.ts | 24 +- packages/plugin/src/modules/Registrar.ts | 64 +++- packages/plugin/src/sdk/index.ts | 3 + packages/plugin/src/settings/controls.ts | 113 +++--- packages/plugin/src/settings/development.ts | 172 +++++---- packages/plugin/src/settings/features.ts | 146 ++++---- packages/plugin/src/settings/filter.ts | 95 ++--- packages/plugin/src/settings/head.ts | 34 +- packages/plugin/src/settings/miscellaneous.ts | 85 +++-- packages/plugin/src/settings/utils.ts | 16 +- packages/plugin/tsdown.config.ts | 2 +- packages/s3/src/index.ts | 2 +- packages/s3/src/setting.ts | 337 +++++++++--------- packages/smart-merge/src/index.ts | 2 +- packages/smart-merge/src/setting.ts | 85 +++-- packages/webdav/src/index.ts | 2 +- packages/webdav/src/setting.ts | 226 ++++++------ 31 files changed, 881 insertions(+), 718 deletions(-) rename packages/plugin/dist/{index-CWzJYpA4.spec.d.ts => index-BvcV-epZ.spec.d.ts} (94%) diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml index 77e6ccae..4a8a8032 100644 --- a/.github/workflows/release-plugin.yml +++ b/.github/workflows/release-plugin.yml @@ -43,7 +43,6 @@ jobs: with: subject-path: | dist/main.js - dist/manifest.json dist/styles.css - name: Create release diff --git a/AGENTS.md b/AGENTS.md index a108d195..82e00e27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ This is the monorepo for an extensible Obsidian syncing plugin to sync vault fil - Excluding main plugin, shared utils and documentation site, all packages are Sync Engine modules, they use the SDK and follow unified module structure. - `null` forbidden, use `undefined` consistently. - Lint warnings must be cleared, except time-bounded ones (TODO with date, deprecated API for compat) -- SDK types (`**/*.d.ts` in `packages/plugin/dist/`) are committed to satisfy Obsidian automated linting. You must not touch these types. +- SDK types (`**/*.d.ts` in `packages/plugin/dist/`) are committed to satisfy Obsidian automated linting. Never edit, delete, restore, clean, or otherwise alter these files, even when builds or checks create uncommitted changes. Leave their existing worktree state unchanged. ## Documentation diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts index 63ad74b5..c0dd32cb 100644 --- a/packages/encryption/src/index.ts +++ b/packages/encryption/src/index.ts @@ -54,7 +54,7 @@ export default class Encryption { priority: 7919, }), registerSetting({ - apply: () => encryptionSetting(this.ctx as Context, this.moduleSettings), + apply: encryptionSetting(this.ctx as Context, this.moduleSettings), priority: 1355, }), ); diff --git a/packages/encryption/src/setting.ts b/packages/encryption/src/setting.ts index e8f6c66b..00a96e4d 100644 --- a/packages/encryption/src/setting.ts +++ b/packages/encryption/src/setting.ts @@ -1,7 +1,13 @@ import type { EncryptionSettings } from '@'; -import type { Context, Fragment, MaybePromise, Translate } from '@hesprs/sync-engine-sdk'; -import type { App, SettingDefinitionItem } from 'obsidian'; -import { setNeedMigration } from '@hesprs/sync-engine-sdk'; +import type { + CallableOrObjectTree, + Context, + Fragment, + MaybePromise, + Translate, +} from '@hesprs/sync-engine-sdk'; +import type { App } from 'obsidian'; +import { s, setNeedMigration } from '@hesprs/sync-engine-sdk'; import { SecretComponent } from 'obsidian'; export type EncryptionTranslations = { @@ -18,35 +24,38 @@ export default function encryptionSetting( recordStoreExists: () => MaybePromise; }, settings: EncryptionSettings, -): Array { +): CallableOrObjectTree { const { translate, app, saveSettings, recordStoreExists } = ctx; - return [ - { - desc: translate('encryptionDescription'), - name: translate('encryption'), - render: (setting) => { - setting - .addComponent((element) => - new SecretComponent(app, element) - .setValue(settings.password) - .onChange((value) => { - settings.password = value; - void saveSettings(); + return { + 1000: { + 6037: s(() => ({ + desc: translate('encryptionDescription'), + name: translate('encryption'), + render: (setting) => { + setting + .setClass('sync-engine-togglable-value') + .addComponent((element) => + new SecretComponent(app, element) + .setValue(settings.password) + .onChange((value) => { + settings.password = value; + void saveSettings(); + }), + ) + .addToggle((toggle) => + setNeedMigration(ctx as Context, { + apply: (value) => { + settings.enabled = value; + void saveSettings(); + }, + content: (value) => + translate('encryptionMigration', value ? 'enable' : 'disable'), + needMigration: recordStoreExists, + toggle: toggle.setValue(settings.enabled), }), - ) - .addToggle((toggle) => - setNeedMigration(ctx as Context, { - apply: (value) => { - settings.enabled = value; - void saveSettings(); - }, - content: (value) => - translate('encryptionMigration', value ? 'enable' : 'disable'), - needMigration: recordStoreExists, - toggle: toggle.setValue(settings.enabled), - }), - ); - }, + ); + }, + })), }, - ]; + }; } diff --git a/packages/i18n/src/ru/translations.ts b/packages/i18n/src/ru/translations.ts index c3a19cf8..135ac067 100644 --- a/packages/i18n/src/ru/translations.ts +++ b/packages/i18n/src/ru/translations.ts @@ -64,7 +64,6 @@ const ru: Translations = { 'Sync Engine записывает состояния синхронизации для разрешения операций между локальными и удалёнными файлами. Эта опция позволяет выборочно очищать записи. Внимание: это действие может привести к потере данных.', completed: 'Завершено', completedNoop: 'Уже синхронизировано', - configurations: 'Конфигурации', configure: 'Настроить', confirm: 'Подтвердить', confirmDeleteDescription: @@ -220,6 +219,9 @@ const ru: Translations = { moduleManagementDescription: 'Управление модулями в специальной панели. Вы можете устанавливать, удалять, обновлять, включать, отключать и редактировать модули, а также их источники.', moduleSourcePlaceholder: 'https://example.com/modules.json', + moduleSources: 'Источники модулей', + moduleSourcesDescription: + 'Редактируйте источники модулей, из которых формируется каталог. Это позволяет устанавливать сторонние модули Sync Engine.', moveLocal: 'Переместить локальный файл', moveRemote: 'Переместить удалённый файл', name: 'Название', @@ -258,6 +260,8 @@ const ru: Translations = { showInstalledOnly: 'Только установленные', showProgress: 'Показывать прогресс', skip: 'Пропустить', + someModulesHidden: + 'Некоторые модули скрыты, поскольку плагин Sync Engine устарел. Обновите его, чтобы просмотреть полный каталог модулей.', sourcesDescription: 'Добавьте URL-адреса источников модулей. Пустые и недействительные строки будут пропущены при сохранении.', startMigration: 'Начать миграцию', @@ -320,6 +324,7 @@ const ru: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: 'Загрузить', walkingRemote: 'Сканирование удалённых файлов', + xEnabled: 'Включено модулей: {{x}}', }; export default ru; diff --git a/packages/i18n/src/zh-TW/translations.ts b/packages/i18n/src/zh-TW/translations.ts index 2bf6b316..c7824edc 100644 --- a/packages/i18n/src/zh-TW/translations.ts +++ b/packages/i18n/src/zh-TW/translations.ts @@ -64,7 +64,6 @@ const zhTW: Translations = { 'Sync Engine 會記錄同步狀態以處理本地與遠端檔案之間的變更。此選項允許您選擇性地清除紀錄。警告:此操作可能會導致資料遺失。', completed: '已完成', completedNoop: '已是最新狀態', - configurations: '設定項目', configure: '設定', confirm: '確認', confirmDeleteDescription: '請確認要刪除的檔案,未勾選的任務將會重新上傳。', @@ -213,6 +212,8 @@ const zhTW: Translations = { moduleManagementDescription: '在專屬面板中管理模組。您可以進行安裝、卸載、更新、啟用、停用、編輯模組或編輯模組來源。', moduleSourcePlaceholder: 'https://example.com/modules.json', + moduleSources: '模組來源', + moduleSourcesDescription: '編輯取得模組目錄的模組來源,以便安裝第三方 Sync Engine 模組。', moveLocal: '移動本地', moveRemote: '移動遠端', name: '名稱', @@ -249,6 +250,8 @@ const zhTW: Translations = { showInstalledOnly: '僅顯示已安裝', showProgress: '顯示進度', skip: '跳過', + someModulesHidden: + '由於 Sync Engine 外掛程式版本過舊,部分模組已隱藏。請更新外掛程式以查看完整模組目錄。', sourcesDescription: '新增模組來源 URL。儲存時將自動忽略空白與無效的資料列。', startMigration: '開始遷移', startNonInteractiveSync: '啟動非互動式同步', @@ -306,6 +309,7 @@ const zhTW: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: '上傳', walkingRemote: '正在掃描遠端檔案', + xEnabled: '已啟用 {{x}} 個模組', }; export default zhTW; diff --git a/packages/i18n/src/zh/translations.ts b/packages/i18n/src/zh/translations.ts index c1d53537..d6a2f95f 100644 --- a/packages/i18n/src/zh/translations.ts +++ b/packages/i18n/src/zh/translations.ts @@ -52,7 +52,6 @@ const zh: Translations = { 'Sync Engine 会记录同步状态,以便在本地和远程文件之间解析同步操作。此选项允许您选择性地清除记录。警告:此操作很可能会导致数据丢失。', completed: '已完成', completedNoop: '已是最新状态', - configurations: '配置', configure: '配置', confirm: '确认', confirmDeleteDescription: '请确认将被删除的文件,未勾选的任务将会被重新上传。', @@ -199,6 +198,8 @@ const zh: Translations = { moduleManagementDescription: '在专用面板中管理模块。您可以安装、卸载、更新、启用、禁用、编辑模块,或编辑模块源。', moduleSourcePlaceholder: 'https://example.com/modules.json', + moduleSources: '模块源', + moduleSourcesDescription: '编辑获取模块目录的模块源,以便安装第三方 Sync Engine 模块。', moveLocal: '移动本地', moveRemote: '移动远程', name: '名称', @@ -236,6 +237,8 @@ const zh: Translations = { showInstalledOnly: '仅显示已安装', showProgress: '显示进度', skip: '跳过', + someModulesHidden: + '由于 Sync Engine 插件版本过旧,部分模块已隐藏。请更新插件以查看完整模块目录。', sourcesDescription: '添加模块源 URL。保存时将忽略空白行和无效行。', startMigration: '开始迁移', startNonInteractiveSync: '开始静默同步', @@ -292,6 +295,7 @@ const zh: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: '上传', walkingRemote: '正在探测远程文件', + xEnabled: '已启用 {{x}} 个模块', }; export default zh; diff --git a/packages/plugin/dist/dev.spec.d.ts b/packages/plugin/dist/dev.spec.d.ts index 60675c6b..fae82550 100644 --- a/packages/plugin/dist/dev.spec.d.ts +++ b/packages/plugin/dist/dev.spec.d.ts @@ -1,4 +1,4 @@ -import { Ct as FolderStat, Dt as RecordStatsMap, Et as RecordStat, J as TaskNames, M as Decider, Ot as Stat, St as FileStat, f as Request, kt as StatsMap, p as RequestParam, ut as Fs, vt as RootFs, xt as Binary, yt as WrappedFs } from "./index-CWzJYpA4.spec.js"; +import { At as RecordStatsMap, Et as FolderStat, F as Decider, Mt as StatsMap, St as WrappedFs, Tt as FileStat, Z as TaskNames, jt as Stat, kt as RecordStat, m as RequestParam, p as Request, pt as Fs, wt as Binary, xt as RootFs } from "./index-BvcV-epZ.spec.js"; //#region src/sdk/debug-wrapper.d.ts declare function debugWrapper(original: Fs, log: (content: string) => void): WrappedFs; //#endregion diff --git a/packages/plugin/dist/index-CWzJYpA4.spec.d.ts b/packages/plugin/dist/index-BvcV-epZ.spec.d.ts similarity index 94% rename from packages/plugin/dist/index-CWzJYpA4.spec.d.ts rename to packages/plugin/dist/index-BvcV-epZ.spec.d.ts index c343da90..0f06ef6c 100644 --- a/packages/plugin/dist/index-CWzJYpA4.spec.d.ts +++ b/packages/plugin/dist/index-BvcV-epZ.spec.d.ts @@ -1,4 +1,4 @@ -import { App, Command, EventRef, IconName, ListedFiles, Modal, Plugin, RequestUrlParam, SettingDefinitionItem, Stat, ToggleComponent } from "obsidian"; +import { App, Command, EventRef, IconName, ListedFiles, Modal, Plugin, RequestUrlParam, Setting, SettingDefinitionItem, Stat, ToggleComponent } from "obsidian"; //#region test/e2e-utils.d.ts type General$1 = any; //#endregion @@ -650,6 +650,9 @@ declare function pipe({ from, to, stat, key }: { declare function readWithSize(fs: Fs, key: string, stat: FileStat): Promise | undefined>; declare function writeWithValue(fs: Fs, key: string, value: Binary | ReadableStream, stat: FileStat): MaybePromise; //#endregion +//#region src/settings/utils.d.ts +declare function s(parent: (self: SettingTree) => SettingDefinitionItem, children?: CallableOrObjectTree): CallableOrObjectTree; +//#endregion //#region src/sdk/index.d.ts declare function digOriginal(wrapped: Fs): RootFs; type SelectFromContext = Context extends O ? O : never; @@ -734,6 +737,7 @@ declare class Extensibility { loadAllModules: () => Promise; loadModule: (meta: AugmentedModuleMeta, start?: boolean, module?: string) => Promise; loadedModules: Map; + pluginOutdated: boolean; unloadModule: (id: string) => void; updateModuleMeta: (meta: AugmentedModuleMeta) => Promise; updateModules: () => Promise; @@ -794,6 +798,19 @@ type ControlsSettingTranslations = { invalidValue: string; }; //#endregion +//#region src/components/SourceEditorModal.d.ts +type SourceEditorTranslations = { + add: string; + cancel: string; + editSources: string; + omittedInvalidEntry: string; + moduleSourcePlaceholder: string; + remove: string; + save: string; + sourcesDescription: string; + httpInsecureWarning: string; +}; +//#endregion //#region src/settings/development.d.ts type DevelopmentSettingTranslations = { development: string; @@ -805,7 +822,10 @@ type DevelopmentSettingTranslations = { exportLogsDescription: string; exportLogsDirectoryPlaceholder: string; exportLogsToFile: string; -}; + moduleSources: string; + moduleSourcesDescription: string; + edit: string; +} & SourceEditorTranslations; //#endregion //#region src/settings/features.d.ts type FeaturesSettingTranslations = { @@ -861,6 +881,7 @@ type HeadSettingTranslations = { checkConnection: string; conflictResolveStrategy: string; conflictResolveStrategyDescription: string; + xEnabled: string; }; //#endregion //#region src/settings/miscellaneous.d.ts @@ -896,28 +917,14 @@ type ModuleManagementTranslations = { deleteModule: string; editModuleInformation: string; official: string; -}; -//#endregion -//#region src/components/SourceEditorModal.d.ts -type SourceEditorTranslations = { - add: string; - cancel: string; - editSources: string; - omittedInvalidEntry: string; - moduleSourcePlaceholder: string; - remove: string; - save: string; - sourcesDescription: string; - httpInsecureWarning: string; + someModulesHidden: string; }; //#endregion //#region src/settings/module-management.d.ts -type ModulesManagementTranslations = ModuleManagementTranslations & SourceEditorTranslations & { +type ModulesTranslations = ModuleManagementTranslations & { searchModules: string; - editSources: string; moduleManagement: string; showInstalledOnly: string; - configurations: string; }; //#endregion //#region src/modules/Bootstrap.d.ts @@ -953,7 +960,7 @@ declare class Bootstrap { keepRemote: string; renameAndKeepBoth: string; skip: string; - } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations & ModulesManagementTranslations; + } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations & ModulesTranslations; readonly settings: { maxMemoryConsumption: TogglableValue; maxRequestConcurrency: TogglableValue; @@ -961,7 +968,6 @@ declare class Bootstrap { realtimeSyncFastMode: boolean; asymmetricStorage: boolean; customHeaders: CustomHeaders; - moduleSources: Array; }; constructor(ctx: { app: App; @@ -1135,9 +1141,20 @@ type RemoteLister = (info: Infras & { }) => MaybePromise>; type RemoteListerEntry = OrderedApplyEntry; type OptimizerEntry = OrderedApplyEntry; +type SettingTree = { + (self: SettingTree): SettingDefinitionItem; + [key: number]: SettingTree; +}; +type NestedCallableTree = { + (self: SettingTree): SettingDefinitionItem; + [key: number]: CallableOrObjectTree; +}; +type CallableOrObjectTree = NestedCallableTree | { + [key: number]: CallableOrObjectTree; +}; type SettingEntry = { priority: number; - apply: () => Array; + apply: CallableOrObjectTree; }; type RequestParam = Omit & { body?: string | Binary; @@ -1296,4 +1313,4 @@ type VaultRequestResponseMap = { }; type VaultRequest = (params: T) => Promise; //#endregion -export { TranslationResource as $, SyncTerminateReason as A, MoveRemote as B, SelectFromContext as C, FolderStat as Ct, writeWithValue as D, RecordStatsMap as Dt, readWithSize as E, RecordStat as Et, Upload as F, BaseTask as G, Download as H, ResolveConflict as I, TaskNames as J, ConflictResolver as K, RemoveRemote as L, Decider as M, DeciderInput as N, prefixWrapper as O, Stat$1 as Ot, TaskFactory as P, Translate as Q, RemoveRecord as R, ModuleMeta as S, FileStat as St, pipe as T, Progress as Tt, CreateRemoteDir as U, MoveLocal as V, AddRecord as W, Fragment as X, RecordStore as Y, ObsidianLanguageCode as Z, Events as _, OutputAtom as _t, FsWrapperEntry as a, StoreOperations as at, ExistingMemoryDB as b, WriteAtom as bt, RemoteFsEntry as c, CustomAtom as ct, RemoteRequestMiddlewareEntry as d, InputAtom as dt, Dispatch as et, Request as f, ListReporter as ft, Context as g, OptimizerOutput as gt, SettingEntry as h, OptimizerInput as ht, DeciderEntry as i, StoreAsync as it, CreateLocalDir as j, setNeedMigration as k, StatsMap as kt, RemoteLister as l, DeleteAtom as lt, RequestResponse as m, MoveAtom as mt, CheckConnectionResult as n, DatabaseAsync as nt, LocalRequestMiddlewareEntry as o, StoreSync as ot, RequestParam as p, MkdirAtom as pt, ConflictResolverPayload as q, ConflictResolverEntry as r, DatabaseSync as rt, OptimizerEntry as s, BatchOptimizer as st, VaultRequest as t, On as tt, RemoteListerEntry as u, Fs as ut, Settings as v, RootFs as vt, digOriginal as w, MaybePromise as wt, AugmentedModuleMeta as x, Binary as xt, Translations as y, WrappedFs as yt, RemoveLocal as z }; \ No newline at end of file +export { Fragment as $, writeWithValue as A, RecordStatsMap as At, RemoveRemote as B, AugmentedModuleMeta as C, WriteAtom as Ct, s as D, MaybePromise as Dt, digOriginal as E, FolderStat as Et, Decider as F, Download as G, RemoveLocal as H, DeciderInput as I, BaseTask as J, CreateRemoteDir as K, TaskFactory as L, setNeedMigration as M, StatsMap as Mt, SyncTerminateReason as N, pipe as O, Progress as Ot, CreateLocalDir as P, RecordStore as Q, Upload as R, ExistingMemoryDB as S, WrappedFs as St, SelectFromContext as T, FileStat as Tt, MoveRemote as U, RemoveRecord as V, MoveLocal as W, ConflictResolverPayload as X, ConflictResolver as Y, TaskNames as Z, SettingTree as _, MoveAtom as _t, DeciderEntry as a, DatabaseAsync as at, Settings as b, OutputAtom as bt, OptimizerEntry as c, StoreOperations as ct, RemoteListerEntry as d, CustomAtom as dt, ObsidianLanguageCode as et, RemoteRequestMiddlewareEntry as f, DeleteAtom as ft, SettingEntry as g, MkdirAtom as gt, RequestResponse as h, ListReporter as ht, ConflictResolverEntry as i, On as it, prefixWrapper as j, Stat$1 as jt, readWithSize as k, RecordStat as kt, RemoteFsEntry as l, StoreSync as lt, RequestParam as m, InputAtom as mt, CallableOrObjectTree as n, TranslationResource as nt, FsWrapperEntry as o, DatabaseSync as ot, Request as p, Fs as pt, AddRecord as q, CheckConnectionResult as r, Dispatch as rt, LocalRequestMiddlewareEntry as s, StoreAsync as st, VaultRequest as t, Translate as tt, RemoteLister as u, BatchOptimizer as ut, Context as v, OptimizerInput as vt, ModuleMeta as w, Binary as wt, Translations as x, RootFs as xt, Events as y, OptimizerOutput as yt, ResolveConflict as z }; \ No newline at end of file diff --git a/packages/plugin/dist/index.spec.d.ts b/packages/plugin/dist/index.spec.d.ts index a32b09bc..c24c3eb9 100644 --- a/packages/plugin/dist/index.spec.d.ts +++ b/packages/plugin/dist/index.spec.d.ts @@ -1,2 +1,2 @@ -import { $ as TranslationResource, A as SyncTerminateReason, B as MoveRemote, C as SelectFromContext, Ct as FolderStat, D as writeWithValue, Dt as RecordStatsMap, E as readWithSize, Et as RecordStat, F as Upload, G as BaseTask, H as Download, I as ResolveConflict, J as TaskNames, K as ConflictResolver, L as RemoveRemote, M as Decider, N as DeciderInput, O as prefixWrapper, Ot as Stat, P as TaskFactory, Q as Translate, R as RemoveRecord, S as ModuleMeta, St as FileStat, T as pipe, Tt as Progress, U as CreateRemoteDir, V as MoveLocal, W as AddRecord, X as Fragment, Y as RecordStore, Z as ObsidianLanguageCode, _ as Events, _t as OutputAtom, a as FsWrapperEntry, at as StoreOperations, b as ExistingMemoryDB, bt as WriteAtom, c as RemoteFsEntry, ct as CustomAtom, d as RemoteRequestMiddlewareEntry, dt as InputAtom, et as Dispatch, f as Request, ft as ListReporter, g as Context, gt as OptimizerOutput, h as SettingEntry, ht as OptimizerInput, i as DeciderEntry, it as StoreAsync, j as CreateLocalDir, k as setNeedMigration, kt as StatsMap, l as RemoteLister, lt as DeleteAtom, m as RequestResponse, mt as MoveAtom, n as CheckConnectionResult, nt as DatabaseAsync, o as LocalRequestMiddlewareEntry, ot as StoreSync, p as RequestParam, pt as MkdirAtom, q as ConflictResolverPayload, r as ConflictResolverEntry, rt as DatabaseSync, s as OptimizerEntry, st as BatchOptimizer, t as VaultRequest, tt as On, u as RemoteListerEntry, ut as Fs, v as Settings, vt as RootFs, w as digOriginal, wt as MaybePromise, x as AugmentedModuleMeta, xt as Binary, y as Translations, yt as WrappedFs, z as RemoveLocal } from "./index-CWzJYpA4.spec.js"; -export { type AddRecord, type AugmentedModuleMeta, type BaseTask, type BatchOptimizer, type Binary, type CheckConnectionResult, type ConflictResolver, type ConflictResolverEntry, type ConflictResolverPayload, type Context, type CreateLocalDir, type CreateRemoteDir, type CustomAtom, type DatabaseAsync, type DatabaseSync, type Decider, type DeciderEntry, type DeciderInput, type DeleteAtom, type Dispatch, type Download, type Events, type ExistingMemoryDB, type FileStat, type FolderStat, type Fragment, type Fs, type FsWrapperEntry, type InputAtom, type ListReporter, type LocalRequestMiddlewareEntry, type MaybePromise, type MkdirAtom, type ModuleMeta, type MoveAtom, type MoveLocal, type MoveRemote, type ObsidianLanguageCode, type On, type OptimizerEntry, type OptimizerInput, type OptimizerOutput, type OutputAtom, type Progress, type RecordStat, type RecordStatsMap, type RecordStore, type RemoteFsEntry, type RemoteLister, type RemoteListerEntry, type RemoteRequestMiddlewareEntry, type RemoveLocal, type RemoveRecord, type RemoveRemote, type Request, type RequestParam, type RequestResponse, type ResolveConflict, type RootFs, SelectFromContext, type SettingEntry, type Settings, type Stat, type StatsMap, type StoreAsync, type StoreOperations, type StoreSync, type SyncTerminateReason, type TaskFactory, type TaskNames, type Translate, type TranslationResource, type Translations, type Upload, type VaultRequest, type WrappedFs, type WriteAtom, digOriginal, pipe, prefixWrapper, readWithSize, setNeedMigration, writeWithValue }; \ No newline at end of file +import { $ as Fragment, A as writeWithValue, At as RecordStatsMap, B as RemoveRemote, C as AugmentedModuleMeta, Ct as WriteAtom, D as s, Dt as MaybePromise, E as digOriginal, Et as FolderStat, F as Decider, G as Download, H as RemoveLocal, I as DeciderInput, J as BaseTask, K as CreateRemoteDir, L as TaskFactory, M as setNeedMigration, Mt as StatsMap, N as SyncTerminateReason, O as pipe, Ot as Progress, P as CreateLocalDir, Q as RecordStore, R as Upload, S as ExistingMemoryDB, St as WrappedFs, T as SelectFromContext, Tt as FileStat, U as MoveRemote, V as RemoveRecord, W as MoveLocal, X as ConflictResolverPayload, Y as ConflictResolver, Z as TaskNames, _ as SettingTree, _t as MoveAtom, a as DeciderEntry, at as DatabaseAsync, b as Settings, bt as OutputAtom, c as OptimizerEntry, ct as StoreOperations, d as RemoteListerEntry, dt as CustomAtom, et as ObsidianLanguageCode, f as RemoteRequestMiddlewareEntry, ft as DeleteAtom, g as SettingEntry, gt as MkdirAtom, h as RequestResponse, ht as ListReporter, i as ConflictResolverEntry, it as On, j as prefixWrapper, jt as Stat, k as readWithSize, kt as RecordStat, l as RemoteFsEntry, lt as StoreSync, m as RequestParam, mt as InputAtom, n as CallableOrObjectTree, nt as TranslationResource, o as FsWrapperEntry, ot as DatabaseSync, p as Request, pt as Fs, q as AddRecord, r as CheckConnectionResult, rt as Dispatch, s as LocalRequestMiddlewareEntry, st as StoreAsync, t as VaultRequest, tt as Translate, u as RemoteLister, ut as BatchOptimizer, v as Context, vt as OptimizerInput, w as ModuleMeta, wt as Binary, x as Translations, xt as RootFs, y as Events, yt as OptimizerOutput, z as ResolveConflict } from "./index-BvcV-epZ.spec.js"; +export { type AddRecord, type AugmentedModuleMeta, type BaseTask, type BatchOptimizer, type Binary, type CallableOrObjectTree, type CheckConnectionResult, type ConflictResolver, type ConflictResolverEntry, type ConflictResolverPayload, type Context, type CreateLocalDir, type CreateRemoteDir, type CustomAtom, type DatabaseAsync, type DatabaseSync, type Decider, type DeciderEntry, type DeciderInput, type DeleteAtom, type Dispatch, type Download, type Events, type ExistingMemoryDB, type FileStat, type FolderStat, type Fragment, type Fs, type FsWrapperEntry, type InputAtom, type ListReporter, type LocalRequestMiddlewareEntry, type MaybePromise, type MkdirAtom, type ModuleMeta, type MoveAtom, type MoveLocal, type MoveRemote, type ObsidianLanguageCode, type On, type OptimizerEntry, type OptimizerInput, type OptimizerOutput, type OutputAtom, type Progress, type RecordStat, type RecordStatsMap, type RecordStore, type RemoteFsEntry, type RemoteLister, type RemoteListerEntry, type RemoteRequestMiddlewareEntry, type RemoveLocal, type RemoveRecord, type RemoveRemote, type Request, type RequestParam, type RequestResponse, type ResolveConflict, type RootFs, SelectFromContext, type SettingEntry, type SettingTree, type Settings, type Stat, type StatsMap, type StoreAsync, type StoreOperations, type StoreSync, type SyncTerminateReason, type TaskFactory, type TaskNames, type Translate, type TranslationResource, type Translations, type Upload, type VaultRequest, type WrappedFs, type WriteAtom, digOriginal, pipe, prefixWrapper, readWithSize, s, setNeedMigration, writeWithValue }; \ No newline at end of file diff --git a/packages/plugin/src/components/FilterEditorModal.ts b/packages/plugin/src/components/FilterEditorModal.ts index c5561fb2..eb797241 100644 --- a/packages/plugin/src/components/FilterEditorModal.ts +++ b/packages/plugin/src/components/FilterEditorModal.ts @@ -30,6 +30,7 @@ export default class FilterEditorModal extends Modal { filters: Array = [], ) { super(ctx.app); + this.contentEl.addClass('markdown-rendered'); this.filters = structuredClone(filters); this.t = ctx.translate; } diff --git a/packages/plugin/src/components/MigrationModal.ts b/packages/plugin/src/components/MigrationModal.ts index e6609f90..f111ba43 100644 --- a/packages/plugin/src/components/MigrationModal.ts +++ b/packages/plugin/src/components/MigrationModal.ts @@ -53,6 +53,8 @@ class MigrationModal extends Modal { }, ) { super(ctx.app); + this.contentEl.addClass('markdown-rendered'); + this.setTitle(ctx.translate('remoteMigration')); } onOpen() { @@ -61,10 +63,7 @@ class MigrationModal extends Modal { options: { content, apply }, } = this; const { translate } = this.ctx; - contentEl.empty(); - contentEl.addClass('markdown-rendered'); - this.setTitle(translate('remoteMigration')); if (typeof content === 'string') contentEl.createEl('p', { cls: 'whitespace-pre-wrap', text: content }); diff --git a/packages/plugin/src/en.ts b/packages/plugin/src/en.ts index 19395ebe..d739b965 100644 --- a/packages/plugin/src/en.ts +++ b/packages/plugin/src/en.ts @@ -219,7 +219,7 @@ const en: Translations = { moduleManagementDescription: 'Manage modules in a dedicated page. You can install, uninstall, update, enable, disable, and edit modules.', moduleSourcePlaceholder: 'https://example.com/modules.json', - moduleSources: 'Module Sources', + moduleSources: 'Module sources', moduleSourcesDescription: 'Edit module sources from which the module catalog is obtained. In this way you can install third-party Sync Engine modules.', moveLocal: 'Move local', @@ -229,7 +229,6 @@ const en: Translations = { noInstalledModulesFound: 'No installed modules found.', noMatchingModulesFound: 'No matching modules found.', noModulesAvailable: 'No modules available.', - noSourceConfigured: 'No source configured', none: 'None', noticeStatusOnMobile: 'Notice sync status on mobile', noticeStatusOnMobileDescription: @@ -323,6 +322,7 @@ const en: Translations = { upload: 'Upload', walkingRemote: 'Discovering remote files', xEnabled: '{{x}} enabled', + xSources: '{{x}} sources', }; export default en; diff --git a/packages/plugin/src/global.css b/packages/plugin/src/global.css index 9efa385e..fd49610e 100644 --- a/packages/plugin/src/global.css +++ b/packages/plugin/src/global.css @@ -54,21 +54,16 @@ input[type='checkbox']:indeterminate { @container (max-width: 500px) { .sync-engine-togglable-value { flex-direction: column; + .setting-item-control { + width: 100%; + justify-content: center; + input[type='text'] { + flex: 1; + } + } } } -.sync-engine-large-modal { - width: var(--modal-width); - height: var(--modal-height); - max-width: var(--modal-max-width); - max-height: var(--modal-max-height); -} - -.is-phone .sync-engine-large-modal { - height: 100%; - margin-top: auto; -} - .sync-engine-card .flair { margin: 0; } diff --git a/packages/plugin/src/modules/Bootstrap.ts b/packages/plugin/src/modules/Bootstrap.ts index dd00a233..63cb40c3 100644 --- a/packages/plugin/src/modules/Bootstrap.ts +++ b/packages/plugin/src/modules/Bootstrap.ts @@ -396,24 +396,12 @@ export default class Bootstrap { resolver: () => {}, }); - registerSetting({ apply: () => headSettings(this.ctx as Context), priority: 0 }); - registerSetting({ - apply: () => featuresSettings(this.ctx as Context), - priority: 1000, - }); - registerSetting({ - apply: () => controlsSettings(this.ctx as Context), - priority: 2000, - }); - registerSetting({ apply: () => filterSettings(this.ctx as Context), priority: 3000 }); - registerSetting({ - apply: () => miscellaneousSettings(this.ctx as Context), - priority: 4000, - }); - registerSetting({ - apply: () => developmentSettings(this.ctx as Context), - priority: 5000, - }); + registerSetting({ apply: headSettings(this.ctx as Context), priority: 0 }); + registerSetting({ apply: featuresSettings(this.ctx as Context), priority: 1000 }); + registerSetting({ apply: controlsSettings(this.ctx as Context), priority: 2000 }); + registerSetting({ apply: filterSettings(this.ctx as Context), priority: 3000 }); + registerSetting({ apply: miscellaneousSettings(this.ctx as Context), priority: 4000 }); + registerSetting({ apply: developmentSettings(this.ctx as Context), priority: 5000 }); this.cleanupCallbacks.push( on('syncStarted', ({ isCancelled }) => { diff --git a/packages/plugin/src/modules/Registrar.ts b/packages/plugin/src/modules/Registrar.ts index b9433d7c..5f2c261a 100644 --- a/packages/plugin/src/modules/Registrar.ts +++ b/packages/plugin/src/modules/Registrar.ts @@ -24,10 +24,7 @@ export type RemoteFsEntry = { checkConnection: (request: Request) => MaybePromise; }; export type DeciderEntry = { decider: Decider; prettyName: () => string }; -export type ConflictResolverEntry = { - prettyName: () => string; - resolver: ConflictResolver; -}; +export type ConflictResolverEntry = { prettyName: () => string; resolver: ConflictResolver }; type GeneralFn = (...args: ReadonlyArray) => unknown; type RejectableApply = (...input: Parameters) => ReturnType | undefined; @@ -39,10 +36,16 @@ export type RemoteLister = ( export type RemoteListerEntry = OrderedApplyEntry; export type OptimizerEntry = OrderedApplyEntry; -export type SettingEntry = { - priority: number; - apply: () => Array; +export type SettingTree = { + (self: SettingTree): SettingDefinitionItem; + [key: number]: SettingTree; }; +type NestedCallableTree = { + (self: SettingTree): SettingDefinitionItem; + [key: number]: CallableOrObjectTree; +}; +export type CallableOrObjectTree = NestedCallableTree | { [key: number]: CallableOrObjectTree }; +export type SettingEntry = { priority: number; apply: CallableOrObjectTree }; export type RequestParam = Omit & { body?: string | Binary }; export type RequestResponse = { @@ -221,9 +224,12 @@ class SettingTab extends PluginSettingTab { getSettingDefinitions() { this.containerEl.empty(); - const sorted: Record Array> = {}; + const sorted: Record = {}; for (const { priority, apply } of this.settingRegistry) sorted[priority] = apply; - return Object.values(sorted).flatMap((render) => render()); + const rootTree = (tree: SettingTree) => Object.values(tree).map((node) => node(node)); + const tree = rootTree as unknown as SettingTree; + for (const patch of Object.values(sorted)) mergeSettingTree(tree, patch); + return rootTree(tree); } } @@ -272,3 +278,43 @@ function mapRegister(registry: Map) { return () => registry.delete(key); }; } + +function toTree(node: CallableOrObjectTree): SettingTree { + const root = ( + typeof node === 'function' ? (self: SettingTree) => node(self) : dummy() + ) as SettingTree; + for (const k of Object.keys(node)) { + const key = Number(k); + root[key] = toTree(node[key]); + } + return root; +} + +function resolveChild( + existing: SettingTree | undefined, + incoming: CallableOrObjectTree, +): SettingTree { + if (!existing) return toTree(incoming); + return typeof incoming === 'function' + ? mergeReversed(incoming, existing) + : mergeSettingTree(existing, incoming); +} + +function mergeSettingTree(a: SettingTree, b: CallableOrObjectTree): SettingTree { + for (const k of Object.keys(b)) { + const key = Number(k); + a[key] = resolveChild(a[key], b[key]); + } + return a; +} + +function mergeReversed(a: NestedCallableTree, b: SettingTree): SettingTree { + const result = toTree(a); + for (const k of Object.keys(b)) { + const index = Number(k); + result[index] = result[index] ? resolveChild(b[index], result[index]) : toTree(b[index]); + } + return result; +} + +const dummy = () => (() => ({ name: 'dummy' })) as unknown as SettingTree; diff --git a/packages/plugin/src/sdk/index.ts b/packages/plugin/src/sdk/index.ts index b603f6bd..1dfb5a4c 100644 --- a/packages/plugin/src/sdk/index.ts +++ b/packages/plugin/src/sdk/index.ts @@ -9,6 +9,7 @@ export function digOriginal(wrapped: Fs) { export { default as setNeedMigration } from '@/components/MigrationModal'; export { default as prefixWrapper } from './prefix'; export { pipe, readWithSize, writeWithValue } from '@/utils/pipe'; +export { s } from '@/settings/utils'; export type { Translate, @@ -65,6 +66,8 @@ export type { CheckConnectionResult, RequestParam, RequestResponse, + CallableOrObjectTree, + SettingTree, } from '@/modules/Registrar'; export type { RecordStore } from '@/modules/Storage'; export type { ModuleMeta, AugmentedModuleMeta } from '@/modules/Extensibility'; diff --git a/packages/plugin/src/settings/controls.ts b/packages/plugin/src/settings/controls.ts index 75be4c4e..cd0bfc2f 100644 --- a/packages/plugin/src/settings/controls.ts +++ b/packages/plugin/src/settings/controls.ts @@ -1,7 +1,8 @@ import type { Settings } from '@'; -import type { SettingDefinitionItem } from 'obsidian'; +import type { SettingGroupItem } from 'obsidian'; import type { Translate } from '@/modules/I18n'; -import { heading, renderTogglableValue } from './utils'; +import type { CallableOrObjectTree } from '@/modules/Registrar'; +import { renderTogglableValue, s } from './utils'; export type ControlsSettingTranslations = { controls: string; @@ -28,56 +29,64 @@ export default function controlsSettings({ translate: Translate; saveSettings: () => Promise; settings: Settings; -}): Array { +}): CallableOrObjectTree { const invalidValue = translate('invalidValue'); - return [ - heading(translate('controls')), - { - desc: translate('maxFileSizeDescription'), - name: translate('maxFileSize'), - render: renderTogglableValue({ - field: settings.maxFileSize, - invalidValue, - placeholder: translate('maxFileSizePlaceholder'), - rejectZero: true, - saveSettings, - type: 'fileSize', + return { + 2000: s( + (self) => ({ + heading: translate('controls'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', }), - }, - { - desc: translate('maxRequestConcurrencyDescription'), - name: translate('maxRequestConcurrency'), - render: renderTogglableValue({ - field: settings.maxRequestConcurrency, - invalidValue, - placeholder: translate('maxRequestConcurrencyPlaceholder'), - rejectZero: true, - saveSettings, - type: 'number', - }), - }, - { - desc: translate('minRequestIntervalDescription'), - name: translate('minRequestInterval'), - render: renderTogglableValue({ - field: settings.minRequestInterval, - invalidValue, - placeholder: translate('minRequestIntervalPlaceholder'), - saveSettings, - type: 'time', - }), - }, - { - desc: translate('maxMemoryConsumptionDescription'), - name: translate('maxMemoryConsumption'), - render: renderTogglableValue({ - field: settings.maxMemoryConsumption, - invalidValue, - placeholder: translate('maxMemoryConsumptionPlaceholder'), - rejectZero: true, - saveSettings, - type: 'fileSize', - }), - }, - ]; + { + 1000: s(() => ({ + desc: translate('maxFileSizeDescription'), + name: translate('maxFileSize'), + render: renderTogglableValue({ + field: settings.maxFileSize, + invalidValue, + placeholder: translate('maxFileSizePlaceholder'), + rejectZero: true, + saveSettings, + type: 'fileSize', + }), + })), + 2000: s(() => ({ + desc: translate('maxRequestConcurrencyDescription'), + name: translate('maxRequestConcurrency'), + render: renderTogglableValue({ + field: settings.maxRequestConcurrency, + invalidValue, + placeholder: translate('maxRequestConcurrencyPlaceholder'), + rejectZero: true, + saveSettings, + type: 'number', + }), + })), + 3000: s(() => ({ + desc: translate('minRequestIntervalDescription'), + name: translate('minRequestInterval'), + render: renderTogglableValue({ + field: settings.minRequestInterval, + invalidValue, + placeholder: translate('minRequestIntervalPlaceholder'), + saveSettings, + type: 'time', + }), + })), + 4000: s(() => ({ + desc: translate('maxMemoryConsumptionDescription'), + name: translate('maxMemoryConsumption'), + render: renderTogglableValue({ + field: settings.maxMemoryConsumption, + invalidValue, + placeholder: translate('maxMemoryConsumptionPlaceholder'), + rejectZero: true, + saveSettings, + type: 'fileSize', + }), + })), + }, + ), + }; } diff --git a/packages/plugin/src/settings/development.ts b/packages/plugin/src/settings/development.ts index bd1b36de..c5c79b2b 100644 --- a/packages/plugin/src/settings/development.ts +++ b/packages/plugin/src/settings/development.ts @@ -1,12 +1,18 @@ import type { Settings } from '@'; -import type { App, SettingDefinitionItem } from 'obsidian'; +import type { + App, + SettingDefinitionGroup, + SettingDefinitionItem, + SettingGroupItem, +} from 'obsidian'; import { normalizeBaseDir } from '@repo/shared/path'; import { Notice } from 'obsidian'; import type { SourceEditorTranslations } from '@/components/SourceEditorModal'; import type { Translate } from '@/modules/I18n'; +import type { CallableOrObjectTree } from '@/modules/Registrar'; import type { MaybePromise } from '@/sdk'; import ModuleSourceEditorModal from '@/components/SourceEditorModal'; -import { heading } from './utils'; +import { s } from './utils'; export type DevelopmentSettingTranslations = { development: string; @@ -21,7 +27,8 @@ export type DevelopmentSettingTranslations = { moduleSources: string; moduleSourcesDescription: string; edit: string; - noSourceConfigured: string; + xSources: string; + addSource: string; } & SourceEditorTranslations; export default function developmentSettings({ @@ -38,72 +45,99 @@ export default function developmentSettings({ settings: Settings; saveSettings: () => Promise; app: App; -}): Array { - return [ - heading(translate('development')), - { - desc: translate('clearRecordsDescription'), - name: translate('clearRecords'), - render: (setting) => { - setting.addButton((button) => - button - .setButtonText(translate('clearRecords')) - .setDestructive() - .onClick(async () => { - await deleteRecordStore(); - new Notice(translate('recordsCleared')); - }), - ); - }, - }, - { - desc: translate('exportLogsDescription'), - name: translate('exportLogsToFile'), - render: (setting) => { - setting - .addText((text) => - text - .setValue(settings.exportLogsDirectory) - .setPlaceholder(translate('exportLogsDirectoryPlaceholder')) - .inputEl.addEventListener('blur', () => { - const normalized = normalizeBaseDir(text.getValue().trim()); - if (settings.exportLogsDirectory !== normalized) { - settings.exportLogsDirectory = normalized; - void saveSettings(); - } - text.setValue(normalized); - }), - ) - .addButton((button) => { - button.setButtonText(translate('export')).onClick(exportLogs); - }); +}): CallableOrObjectTree { + return { + 5000: s( + (self) => ({ + heading: translate('development'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', + }), + { + 1000: s(() => ({ + desc: translate('clearRecordsDescription'), + name: translate('clearRecords'), + render: (setting) => { + setting.addButton((button) => + button + .setButtonText(translate('clearRecords')) + .setDestructive() + .onClick(async () => { + await deleteRecordStore(); + new Notice(translate('recordsCleared')); + }), + ); + }, + })), + 2000: s(() => ({ + desc: translate('exportLogsDescription'), + name: translate('exportLogsToFile'), + render: (setting) => { + setting + .addText((text) => + text + .setValue(settings.exportLogsDirectory) + .setPlaceholder(translate('exportLogsDirectoryPlaceholder')) + .inputEl.addEventListener('blur', () => { + const normalized = normalizeBaseDir(text.getValue().trim()); + if (settings.exportLogsDirectory !== normalized) { + settings.exportLogsDirectory = normalized; + void saveSettings(); + } + text.setValue(normalized); + }), + ) + .addButton((button) => { + button.setButtonText(translate('export')).onClick(exportLogs); + }); + }, + })), + 3000: s(() => ({ + desc: translate('moduleSourcesDescription'), + name: translate('moduleSources'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => + new ModuleSourceEditorModal( + (sources) => { + settings.moduleSources = sources; + void saveSettings(); + }, + { app, translate }, + settings.moduleSources, + ).open(), + ); + }); + }, + })), + 4000: s( + (self) => ({ + desc: translate('moduleSourcesDescription'), + displayValue: translate('xSources', { x: settings.moduleSources.length }), + items: Object.values(self).map((node) => node(node)), + name: translate('moduleSources'), + type: 'page', + }), + { + 1000: s(() => ({ + addItem: { + action: () => {}, + name: translate('addSource'), + }, + items: settings.moduleSources.map(generateEditableItem), + type: 'list', + })), + }, + ), }, - }, - { - desc: translate('moduleSourcesDescription'), - emptyState: translate('noSourceConfigured'), - items: settings.moduleSources.map((source) => ({ - name: source, - })), - name: translate('moduleSources'), - type: 'list', - }, - ]; + ), + }; } -/* -Render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => - new ModuleSourceEditorModal( - (sources) => { - settings.moduleSources = sources; - void saveSettings(); - }, - { app, translate }, - settings.moduleSources, - ).open(), - ); - }); - }, -*/ +function generateEditableItem(source: string): SettingGroupItem { + return { + name: '', + render: (setting) => {}, + searchable: false, + }; +} diff --git a/packages/plugin/src/settings/features.ts b/packages/plugin/src/settings/features.ts index 5468a3ad..ffcd647b 100644 --- a/packages/plugin/src/settings/features.ts +++ b/packages/plugin/src/settings/features.ts @@ -1,10 +1,11 @@ import type { Settings, Context } from '@'; -import type { SettingDefinitionItem } from 'obsidian'; +import type { SettingGroupItem } from 'obsidian'; import type { MigrationModalTranslations } from '@/components/MigrationModal'; import type { Fragment, Translate } from '@/modules/I18n'; +import type { CallableOrObjectTree } from '@/modules/Registrar'; import type { MaybePromise } from '@/sdk'; import setNeedMigration from '@/components/MigrationModal'; -import { heading, renderTogglableValue } from './utils'; +import { renderTogglableValue, s } from './utils'; export type FeaturesSettingTranslations = { features: string; @@ -32,7 +33,7 @@ export default function featuresSettings(ctx: { stopScheduledSync: () => void; settings: Settings; recordStoreExists: () => MaybePromise; -}): Array { +}): CallableOrObjectTree { const { translate, saveSettings, @@ -42,72 +43,83 @@ export default function featuresSettings(ctx: { recordStoreExists, } = ctx; const invalidValue = translate('invalidValue'); - return [ - heading(translate('features')), - { - desc: translate('realtimeSyncDescription'), - name: translate('realtimeSync'), - render: renderTogglableValue({ - field: settings.realtimeSync, - invalidValue, - placeholder: translate('realtimeSyncPlaceholder'), - saveSettings, - type: 'time', + return { + 1000: s( + (self) => ({ + heading: translate('features'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', }), - }, - { - desc: translate('startupSyncDescription'), - name: translate('startupSync'), - render: renderTogglableValue({ - field: settings.startupSync, - invalidValue, - placeholder: translate('startupSyncPlaceholder'), - saveSettings, - type: 'time', - }), - }, - { - desc: translate('scheduledSyncDescription'), - name: translate('scheduledSync'), - render: renderTogglableValue({ - field: settings.scheduledSync, - invalidValue, - onChange: () => { - stopScheduledSync(); - startScheduledSync(); - }, - onToggle: (enabled) => { - if (enabled) startScheduledSync(); - else stopScheduledSync(); - }, - placeholder: translate('scheduledSyncPlaceholder'), - rejectZero: true, - saveSettings, - type: 'time', - }), - }, - { - control: { key: 'realtimeSyncFastMode', type: 'toggle' }, - desc: translate('realtimeSyncFastModeDescription'), - name: translate('realtimeSyncFastMode'), - }, - { - desc: translate('asymmetricStorageDescription'), - name: translate('asymmetricStorage'), - render: (setting) => { - setting.addToggle((toggle) => - setNeedMigration(ctx as Context, { - apply: (value) => { - settings.asymmetricStorage = value; - void saveSettings(); + { + 1000: s(() => ({ + desc: translate('realtimeSyncDescription'), + name: translate('realtimeSync'), + render: renderTogglableValue({ + field: settings.realtimeSync, + invalidValue, + placeholder: translate('realtimeSyncPlaceholder'), + saveSettings, + type: 'time', + }), + })), + 2000: s(() => ({ + desc: translate('startupSyncDescription'), + name: translate('startupSync'), + render: renderTogglableValue({ + field: settings.startupSync, + invalidValue, + placeholder: translate('startupSyncPlaceholder'), + saveSettings, + type: 'time', + }), + })), + 3000: s(() => ({ + desc: translate('scheduledSyncDescription'), + name: translate('scheduledSync'), + render: renderTogglableValue({ + field: settings.scheduledSync, + invalidValue, + onChange: () => { + stopScheduledSync(); + startScheduledSync(); + }, + onToggle: (enabled) => { + if (enabled) startScheduledSync(); + else stopScheduledSync(); }, - content: (value) => - translate('asymmetricStorageMigration', value ? 'enable' : 'disable'), - needMigration: recordStoreExists, - toggle: toggle.setValue(settings.asymmetricStorage), + placeholder: translate('scheduledSyncPlaceholder'), + rejectZero: true, + saveSettings, + type: 'time', }), - ); + })), + 4000: s(() => ({ + control: { key: 'realtimeSyncFastMode', type: 'toggle' }, + desc: translate('realtimeSyncFastModeDescription'), + name: translate('realtimeSyncFastMode'), + })), + 5000: s(() => ({ + desc: translate('asymmetricStorageDescription'), + name: translate('asymmetricStorage'), + render: (setting) => { + setting.addToggle((toggle) => + setNeedMigration(ctx as Context, { + apply: (value) => { + settings.asymmetricStorage = value; + void saveSettings(); + }, + content: (value) => + translate( + 'asymmetricStorageMigration', + value ? 'enable' : 'disable', + ), + needMigration: recordStoreExists, + toggle: toggle.setValue(settings.asymmetricStorage), + }), + ); + }, + })), }, - }, - ]; + ), + }; } diff --git a/packages/plugin/src/settings/filter.ts b/packages/plugin/src/settings/filter.ts index 83846a3a..2cb2b628 100644 --- a/packages/plugin/src/settings/filter.ts +++ b/packages/plugin/src/settings/filter.ts @@ -1,9 +1,10 @@ import type { Settings } from '@'; -import type { App, SettingDefinitionItem } from 'obsidian'; +import type { App, SettingGroupItem } from 'obsidian'; import type { FilterEditorTranslations } from '@/components/FilterEditorModal'; import type { Translate } from '@/modules/I18n'; +import type { CallableOrObjectTree } from '@/modules/Registrar'; import FilterEditorModal from '@/components/FilterEditorModal'; -import { heading } from './utils'; +import { s } from './utils'; export type FilterSettingTranslations = { filterRules: string; @@ -20,46 +21,54 @@ export default function filterSettings({ saveSettings: () => Promise; app: App; settings: Settings; -}): Array { - return [ - heading(translate('filterRules')), - { - desc: translate('inclusionRulesDescription'), - name: translate('inclusionRules'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new FilterEditorModal( - (filters) => { - settings.inclusionRules = filters; - void saveSettings(); - }, - 'include', - { app, translate }, - settings.inclusionRules, - ).open(); - }); - }); +}): CallableOrObjectTree { + return { + 3000: s( + (self) => ({ + heading: translate('filterRules'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', + }), + { + 1000: s(() => ({ + desc: translate('inclusionRulesDescription'), + name: translate('inclusionRules'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => { + new FilterEditorModal( + (filters) => { + settings.inclusionRules = filters; + void saveSettings(); + }, + 'include', + { app, translate }, + settings.inclusionRules, + ).open(); + }); + }); + }, + })), + 2000: s(() => ({ + desc: translate('exclusionRulesDescription'), + name: translate('exclusionRules'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => { + new FilterEditorModal( + (filters) => { + settings.exclusionRules = filters; + void saveSettings(); + }, + 'exclude', + { app, translate }, + settings.exclusionRules, + ).open(); + }); + }); + }, + })), }, - }, - { - desc: translate('exclusionRulesDescription'), - name: translate('exclusionRules'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new FilterEditorModal( - (filters) => { - settings.exclusionRules = filters; - void saveSettings(); - }, - 'exclude', - { app, translate }, - settings.exclusionRules, - ).open(); - }); - }); - }, - }, - ]; + ), + }; } diff --git a/packages/plugin/src/settings/head.ts b/packages/plugin/src/settings/head.ts index 6f2af652..5fdcd9f0 100644 --- a/packages/plugin/src/settings/head.ts +++ b/packages/plugin/src/settings/head.ts @@ -1,10 +1,10 @@ import type { Context, Settings } from '@'; -import type { SettingDefinitionItem } from 'obsidian'; import type { DatabaseSync } from 'uni-kv'; import { ExtraButtonComponent, Notice } from 'obsidian'; import type { ModuleCtor } from '@/modules/Extensibility'; import type { Translate } from '@/modules/I18n'; import type { + CallableOrObjectTree, CheckConnectionResult, ConflictResolverEntry, DeciderEntry, @@ -13,6 +13,7 @@ import type { import type { General, MaybePromise } from '@/types'; import toErrorMessage from '@/utils/to-error-message'; import ModuleManagement from './module-management'; +import { s } from './utils'; const CHECK_CONNECTION_INTERVAL = 10_000; @@ -45,7 +46,7 @@ export default function headSettings(ctx: { getCheckConnection: () => () => MaybePromise; memoryDB: CheckConnectionDB; loadedModules: Map; -}): Array { +}): CallableOrObjectTree { const { loadedModules, translate, @@ -57,8 +58,8 @@ export default function headSettings(ctx: { memoryDB, conflictResolverRegistry, } = ctx; - return [ - { + return { + 10: s(() => ({ desc: translate('backendDescription'), name: translate('backend'), render: (setting) => { @@ -90,23 +91,20 @@ export default function headSettings(ctx: { }); return cleanup; }, - }, - { + })), + 20: s(() => ({ desc: translate('moduleManagementDescription'), displayValue: translate('xEnabled', { x: loadedModules.size }), name: translate('moduleManagement'), page: () => new ModuleManagement(ctx as Context), type: 'page', - }, - { - control: { - key: 'moduleAutoUpdate', - type: 'toggle', - }, + })), + 30: s(() => ({ + control: { key: 'moduleAutoUpdate', type: 'toggle' }, desc: translate('moduleAutoUpdateDescription'), name: translate('moduleAutoUpdate'), - }, - { + })), + 40: s(() => ({ control: { key: 'decider', options: Object.fromEntries( @@ -116,8 +114,8 @@ export default function headSettings(ctx: { }, desc: translate('syncStrategyDescription'), name: translate('syncStrategy'), - }, - { + })), + 50: s(() => ({ control: { key: 'conflictResolver', options: Object.fromEntries( @@ -130,8 +128,8 @@ export default function headSettings(ctx: { }, desc: translate('conflictResolveStrategyDescription'), name: translate('conflictResolveStrategy'), - }, - ]; + })), + }; } function setupCheckConnection({ diff --git a/packages/plugin/src/settings/miscellaneous.ts b/packages/plugin/src/settings/miscellaneous.ts index 14741140..b3aba62d 100644 --- a/packages/plugin/src/settings/miscellaneous.ts +++ b/packages/plugin/src/settings/miscellaneous.ts @@ -1,8 +1,9 @@ import type { Settings } from '@'; -import type { App, SettingDefinitionItem } from 'obsidian'; +import type { App, SettingGroupItem } from 'obsidian'; import type { Translate } from '@/modules/I18n'; +import type { CallableOrObjectTree } from '@/modules/Registrar'; import HeadersEditorModal from '@/components/HeadersEditorModal'; -import { heading } from './utils'; +import { s } from './utils'; export type MiscellaneousSettingTranslations = { miscellaneous: string; @@ -31,41 +32,49 @@ export default function miscellaneousSettings({ saveSettings: () => Promise; settings: Settings; app: App; -}): Array { - return [ - heading(translate('miscellaneous')), - { - desc: translate('customHeadersDescription'), - name: translate('customHeaders'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new HeadersEditorModal( - (headers) => { - settings.customHeaders = headers; - void saveSettings(); - }, - { app, translate }, - settings.customHeaders, - ).open(); - }); - }); +}): CallableOrObjectTree { + return { + 4000: s( + (self) => ({ + heading: translate('miscellaneous'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', + }), + { + 1000: s(() => ({ + desc: translate('customHeadersDescription'), + name: translate('customHeaders'), + render: (setting) => { + setting.addButton((button) => { + button.setButtonText(translate('edit')).onClick(() => { + new HeadersEditorModal( + (headers) => { + settings.customHeaders = headers; + void saveSettings(); + }, + { app, translate }, + settings.customHeaders, + ).open(); + }); + }); + }, + })), + 2000: s(() => ({ + control: { key: 'noticeStatusOnMobile', type: 'toggle' }, + desc: translate('noticeStatusOnMobileDescription'), + name: translate('noticeStatusOnMobile'), + })), + 3000: s(() => ({ + control: { key: 'confirmTasksInSync', type: 'toggle' }, + desc: translate('confirmTasksInSyncDescription'), + name: translate('confirmTasksInSync'), + })), + 4000: s(() => ({ + control: { key: 'confirmDeleteInAutoSync', type: 'toggle' }, + desc: translate('confirmDeleteInAutoSyncDescription'), + name: translate('confirmDeleteInAutoSync'), + })), }, - }, - { - control: { key: 'noticeStatusOnMobile', type: 'toggle' }, - desc: translate('noticeStatusOnMobileDescription'), - name: translate('noticeStatusOnMobile'), - }, - { - control: { key: 'confirmTasksInSync', type: 'toggle' }, - desc: translate('confirmTasksInSyncDescription'), - name: translate('confirmTasksInSync'), - }, - { - control: { key: 'confirmDeleteInAutoSync', type: 'toggle' }, - desc: translate('confirmDeleteInAutoSyncDescription'), - name: translate('confirmDeleteInAutoSync'), - }, - ]; + ), + }; } diff --git a/packages/plugin/src/settings/utils.ts b/packages/plugin/src/settings/utils.ts index 0e635f21..26595ac0 100644 --- a/packages/plugin/src/settings/utils.ts +++ b/packages/plugin/src/settings/utils.ts @@ -1,19 +1,16 @@ import type { Setting, SettingDefinitionItem } from 'obsidian'; +import type { CallableOrObjectTree, SettingTree } from '@/modules/Registrar'; import type { TogglableValue } from '@/types'; import { formatFileSize, formatTime, parseFileSize, parseTime } from '@/utils/unit-converter'; type InputType = 'number' | 'time' | 'fileSize'; - -const MAX_32BIT_VALUE = 2 ** 31 - 1; const WARNING_INTERVAL = 2000; -export function heading(name: string): SettingDefinitionItem { - return { - name, - render: (setting) => { - setting.setHeading(); - }, - }; +export function s( + parent: (self: SettingTree) => SettingDefinitionItem, + children?: CallableOrObjectTree, +): CallableOrObjectTree { + return children ? Object.assign(parent, children) : (parent as unknown as CallableOrObjectTree); } export function renderTogglableValue({ @@ -47,7 +44,6 @@ export function renderTogglableValue({ value === undefined || Number.isNaN(value) || value < 0 || - value > MAX_32BIT_VALUE || (rejectZero && value === 0) ) { text.inputEl.value = format(field.value, type); diff --git a/packages/plugin/tsdown.config.ts b/packages/plugin/tsdown.config.ts index 10ce65d2..067f7a03 100644 --- a/packages/plugin/tsdown.config.ts +++ b/packages/plugin/tsdown.config.ts @@ -45,7 +45,7 @@ const pluginConfig = defineConfig({ const sdkConfig = defineConfig({ ...sharedConfig, - clean: !dev && dtsPass, + clean: dtsPass, dts: dtsPass, entry: { dev: 'src/sdk/dev.ts', index: 'src/sdk/index.ts' }, unbundle: !dtsPass, diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts index 025da520..7a192fa9 100644 --- a/packages/s3/src/index.ts +++ b/packages/s3/src/index.ts @@ -148,7 +148,7 @@ export default class S3 { priority: 303, }), registerSetting({ - apply: () => s3Setting(this.ctx, this.moduleSettings), + apply: s3Setting(this.ctx, this.moduleSettings), priority: 604, }), ); diff --git a/packages/s3/src/setting.ts b/packages/s3/src/setting.ts index 8628d6b2..3ae6bfcf 100644 --- a/packages/s3/src/setting.ts +++ b/packages/s3/src/setting.ts @@ -1,6 +1,12 @@ import type { S3Settings } from '@'; -import type { Fragment, Translate, Translations } from '@hesprs/sync-engine-sdk'; -import type { App, SettingDefinitionItem } from 'obsidian'; +import type { + CallableOrObjectTree, + Fragment, + Translate, + Translations, +} from '@hesprs/sync-engine-sdk'; +import type { App, SettingGroupItem } from 'obsidian'; +import { s } from '@hesprs/sync-engine-sdk'; import { normalizeBaseDir, normalizeUrl } from '@repo/shared/path'; import { Notice, SecretComponent } from 'obsidian'; import type { UrlStyle } from './s3/sigv4'; @@ -45,166 +51,175 @@ export default function s3Setting( app: App; }, settings: S3Settings, -): Array { +): CallableOrObjectTree { const invalidValue = translate('invalidValue'); - return [ - { - name: translate('s3'), - render: (setting) => { - setting.setHeading(); - }, - }, - { - desc: translate('endpointDescription'), - name: translate('endpoint'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('endpointPlaceholder')).setValue( - settings.endpoint, - ); - handleInput({ - invalidValue, - key: 'endpoint', - processValue: (value) => { - try { - return normalizeUrl(value); - } catch { - return false; - } - }, - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('regionDescription'), - name: translate('region'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('regionPlaceholder')).setValue(settings.region); - handleInput({ - invalidValue, - key: 'region', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('accessKeyIdDescription'), - name: translate('accessKeyId'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('accessKeyIdPlaceholder')).setValue( - settings.accessKeyId, - ); - handleInput({ - invalidValue, - key: 'accessKeyId', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('secretAccessKeyDescription'), - name: translate('secretAccessKey'), - render: (setting) => { - setting.addComponent((element) => - new SecretComponent(app, element) - .setValue(settings.secretAccessKey) - .onChange((value) => { - settings.secretAccessKey = value; - void saveSettings(); - }), - ); - }, - }, - { - desc: translate('bucketDescription'), - name: translate('bucket'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('bucketPlaceholder')).setValue(settings.bucket); - handleInput({ - invalidValue, - key: 'bucket', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('urlStyleDescription'), - name: translate('urlStyle'), - render: (setting) => { - setting.addDropdown((dropdown) => - dropdown - .addOption('virtualHosted', translate('urlStyleVirtualHosted')) - .addOption('path', translate('urlStylePath')) - .setValue(settings.urlStyle) - .onChange((value) => { - settings.urlStyle = value as UrlStyle; - void saveSettings(); - }), - ); - }, - }, - { - desc: translate('prefixDescription'), - name: translate('prefix'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('prefixPlaceholder')).setValue(settings.prefix); - handleInput({ - invalidValue, - key: 'prefix', - processValue: (original) => normalizeBaseDir(original.trim()), - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('proxyUrlDescription'), - name: translate('proxyUrl'), - render: (setting) => { - setting - .addText((text) => { - text.setPlaceholder(translate('proxyUrlPlaceholder')) - .setValue(settings.proxyUrl.value) - .inputEl.addEventListener('blur', () => { - const original = settings.proxyUrl.value; - try { - settings.proxyUrl.value = normalizeUrl(text.getValue()); - } catch { - new Notice(translate('invalidValue')); - settings.proxyUrl.value = original; - } - text.setValue(settings.proxyUrl.value); + return { + 604: s( + (self) => ({ + heading: translate('s3'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', + }), + { + 1000: s(() => ({ + desc: translate('endpointDescription'), + name: translate('endpoint'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('endpointPlaceholder')).setValue( + settings.endpoint, + ); + handleInput({ + invalidValue, + key: 'endpoint', + processValue: (value) => { + try { + return normalizeUrl(value); + } catch { + return false; + } + }, + saveSettings, + settings, + text, + }); + }); + }, + })), + 2000: s(() => ({ + desc: translate('regionDescription'), + name: translate('region'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('regionPlaceholder')).setValue( + settings.region, + ); + handleInput({ + invalidValue, + key: 'region', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + })), + 3000: s(() => ({ + desc: translate('accessKeyIdDescription'), + name: translate('accessKeyId'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('accessKeyIdPlaceholder')).setValue( + settings.accessKeyId, + ); + handleInput({ + invalidValue, + key: 'accessKeyId', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + })), + 4000: s(() => ({ + desc: translate('secretAccessKeyDescription'), + name: translate('secretAccessKey'), + render: (setting) => { + setting.addComponent((element) => + new SecretComponent(app, element) + .setValue(settings.secretAccessKey) + .onChange((value) => { + settings.secretAccessKey = value; + void saveSettings(); + }), + ); + }, + })), + 5000: s(() => ({ + desc: translate('bucketDescription'), + name: translate('bucket'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('bucketPlaceholder')).setValue( + settings.bucket, + ); + handleInput({ + invalidValue, + key: 'bucket', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + })), + 6000: s(() => ({ + desc: translate('urlStyleDescription'), + name: translate('urlStyle'), + render: (setting) => { + setting.addDropdown((dropdown) => + dropdown + .addOption('virtualHosted', translate('urlStyleVirtualHosted')) + .addOption('path', translate('urlStylePath')) + .setValue(settings.urlStyle) + .onChange((value) => { + settings.urlStyle = value as UrlStyle; + void saveSettings(); + }), + ); + }, + })), + 7000: s(() => ({ + desc: translate('prefixDescription'), + name: translate('prefix'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('prefixPlaceholder')).setValue( + settings.prefix, + ); + handleInput({ + invalidValue, + key: 'prefix', + processValue: (original) => normalizeBaseDir(original.trim()), + saveSettings, + settings, + text, }); - }) - .addToggle((toggle) => - toggle.setValue(settings.proxyUrl.enabled).onChange((value) => { - settings.proxyUrl.enabled = value; - void saveSettings(); - }), - ); + }); + }, + })), + 8000: s(() => ({ + desc: translate('proxyUrlDescription'), + name: translate('proxyUrl'), + render: (setting) => { + setting + .addText((text) => { + text.setPlaceholder(translate('proxyUrlPlaceholder')) + .setValue(settings.proxyUrl.value) + .inputEl.addEventListener('blur', () => { + const original = settings.proxyUrl.value; + try { + settings.proxyUrl.value = normalizeUrl(text.getValue()); + } catch { + new Notice(translate('invalidValue')); + settings.proxyUrl.value = original; + } + text.setValue(settings.proxyUrl.value); + }); + }) + .addToggle((toggle) => + toggle.setValue(settings.proxyUrl.enabled).onChange((value) => { + settings.proxyUrl.enabled = value; + void saveSettings(); + }), + ); + }, + })), }, - }, - ]; + ), + }; } diff --git a/packages/smart-merge/src/index.ts b/packages/smart-merge/src/index.ts index e6c18ee3..81a88258 100644 --- a/packages/smart-merge/src/index.ts +++ b/packages/smart-merge/src/index.ts @@ -77,7 +77,7 @@ export default class SmartMerge { resolver: smartMergeResolver(this.moduleSettings, indexedDB, getNamespace), }), registerSetting({ - apply: () => smartMergeSetting({ saveSettings, translate }, this.moduleSettings), + apply: smartMergeSetting({ saveSettings, translate }, this.moduleSettings), priority: 4048, }), ); diff --git a/packages/smart-merge/src/setting.ts b/packages/smart-merge/src/setting.ts index c3f8b8d0..6cb4b546 100644 --- a/packages/smart-merge/src/setting.ts +++ b/packages/smart-merge/src/setting.ts @@ -1,5 +1,6 @@ -import type { Translate } from '@hesprs/sync-engine-sdk'; -import type { SettingDefinitionItem, TextComponent } from 'obsidian'; +import type { CallableOrObjectTree, Translate } from '@hesprs/sync-engine-sdk'; +import type { SettingGroupItem, TextComponent } from 'obsidian'; +import { s } from '@hesprs/sync-engine-sdk'; import type { SmartMergeTranslations } from './i18n'; import type { MergeOptions } from './utils/merge'; @@ -11,49 +12,55 @@ export default function smartMergeSetting( saveSettings, }: { translate: Translate; saveSettings: () => Promise }, settings: SmartMergeSettings, -): Array { +): CallableOrObjectTree { const marker = (key: keyof SmartMergeSettings, placeholder: string) => (text: TextComponent) => { text.setValue(settings[key]) .setPlaceholder(placeholder) - .onChange((value: string) => { - settings[key] = value; + .inputEl.addEventListener('blur', () => { + settings[key] = text.getValue(); void saveSettings(); }); }; - return [ - { - name: translate('smartMerge'), - render: (setting) => { - setting.setHeading(); + return { + 4048: s( + (self) => ({ + heading: translate('smartMerge'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', + }), + { + 1000: s(() => ({ + desc: translate('conflictOursMarkersDescription'), + name: translate('conflictOursMarkers'), + render: (setting) => { + setting + .setClass('sync-engine-togglable-value') + .addText(marker('conflictAStart', translate('start'))) + .addText(marker('conflictAEnd', translate('end'))); + }, + })), + 2000: s(() => ({ + desc: translate('conflictTheirsMarkersDescription'), + name: translate('conflictTheirsMarkers'), + render: (setting) => { + setting + .setClass('sync-engine-togglable-value') + .addText(marker('conflictBStart', translate('start'))) + .addText(marker('conflictBEnd', translate('end'))); + }, + })), + 3000: s(() => ({ + desc: translate('deletionMarkersDescription'), + name: translate('deletionMarkers'), + render: (setting) => { + setting + .setClass('sync-engine-togglable-value') + .addText(marker('deletionStart', translate('start'))) + .addText(marker('deletionEnd', translate('end'))); + }, + })), }, - }, - { - desc: translate('conflictOursMarkersDescription'), - name: translate('conflictOursMarkers'), - render: (setting) => { - setting - .addText(marker('conflictAStart', translate('start'))) - .addText(marker('conflictAEnd', translate('end'))); - }, - }, - { - desc: translate('conflictTheirsMarkersDescription'), - name: translate('conflictTheirsMarkers'), - render: (setting) => { - setting - .addText(marker('conflictBStart', translate('start'))) - .addText(marker('conflictBEnd', translate('end'))); - }, - }, - { - desc: translate('deletionMarkersDescription'), - name: translate('deletionMarkers'), - render: (setting) => { - setting - .addText(marker('deletionStart', translate('start'))) - .addText(marker('deletionEnd', translate('end'))); - }, - }, - ]; + ), + }; } diff --git a/packages/webdav/src/index.ts b/packages/webdav/src/index.ts index faf43f28..394a794a 100644 --- a/packages/webdav/src/index.ts +++ b/packages/webdav/src/index.ts @@ -96,7 +96,7 @@ export default class Webdav { priority: 6318, }), registerSetting({ - apply: () => webdavSetting(this.ctx, this.moduleSettings), + apply: webdavSetting(this.ctx, this.moduleSettings), priority: 749, }), ); diff --git a/packages/webdav/src/setting.ts b/packages/webdav/src/setting.ts index 46eec65f..d821d52b 100644 --- a/packages/webdav/src/setting.ts +++ b/packages/webdav/src/setting.ts @@ -1,6 +1,7 @@ import type { WebdavSettings } from '@'; -import type { Translate, Translations } from '@hesprs/sync-engine-sdk'; -import type { App, SettingDefinitionItem } from 'obsidian'; +import type { CallableOrObjectTree, Translate, Translations } from '@hesprs/sync-engine-sdk'; +import type { App, SettingGroupItem } from 'obsidian'; +import { s } from '@hesprs/sync-engine-sdk'; import { normalizeBaseDir, normalizeUrl } from '@repo/shared/path'; import { SecretComponent } from 'obsidian'; import handleInput from './handle-input'; @@ -35,115 +36,118 @@ export default function webdavSetting( app: App; }, settings: WebdavSettings, -): Array { +): CallableOrObjectTree { const invalidValue = translate('invalidValue'); - return [ - { - name: translate('webdav'), - render: (setting) => { - setting.setHeading(); + return { + 749: s( + (self) => ({ + heading: translate('webdav'), + items: Object.values(self).map((node) => node(node) as SettingGroupItem), + type: 'group', + }), + { + 1000: s(() => ({ + desc: translate('endpointDescription'), + name: translate('endpoint'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('endpointPlaceholder')).setValue( + settings.endpoint, + ); + handleInput({ + invalidValue, + key: 'endpoint', + processValue: (value) => { + try { + return normalizeUrl(value); + } catch { + return false; + } + }, + saveSettings, + settings, + text, + }); + }); + }, + })), + 2000: s(() => ({ + desc: translate('usernameDescription'), + name: translate('username'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('usernamePlaceholder')).setValue( + settings.username, + ); + handleInput({ + invalidValue, + key: 'username', + processValue: (value) => value.trim(), + saveSettings, + settings, + text, + }); + }); + }, + })), + 3000: s(() => ({ + desc: translate('passwordDescription'), + name: translate('password'), + render: (setting) => { + setting.addComponent((element) => + new SecretComponent(app, element) + .setValue(settings.password) + .onChange((password) => { + settings.password = password; + void saveSettings(); + }), + ); + }, + })), + 4000: s(() => ({ + desc: translate('baseDirectoryDescription'), + name: translate('baseDirectory'), + render: (setting) => { + setting.addText((text) => { + text.setPlaceholder(translate('baseDirectoryPlaceholder')).setValue( + settings.baseDirectory, + ); + handleInput({ + invalidValue, + key: 'baseDirectory', + processValue: (original) => normalizeBaseDir(original.trim()), + saveSettings, + settings, + text, + }); + }); + }, + })), + 5000: s(() => ({ + desc: translate('depthInfinityDescription'), + name: translate('depthInfinity'), + render: (setting) => { + setting.addToggle((toggle) => + toggle.setValue(settings.depthInfinity).onChange((value) => { + settings.depthInfinity = value; + void saveSettings(); + }), + ); + }, + })), + 6000: s(() => ({ + desc: translate('chunkedUploadDescription'), + name: translate('chunkedUpload'), + render: (setting) => { + setting.addToggle((toggle) => + toggle.setValue(settings.chunkedUpload).onChange((value) => { + settings.chunkedUpload = value; + void saveSettings(); + }), + ); + }, + })), }, - }, - { - desc: translate('endpointDescription'), - name: translate('endpoint'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('endpointPlaceholder')).setValue( - settings.endpoint, - ); - handleInput({ - invalidValue, - key: 'endpoint', - processValue: (value) => { - try { - return normalizeUrl(value); - } catch { - return false; - } - }, - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('usernameDescription'), - name: translate('username'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('usernamePlaceholder')).setValue( - settings.username, - ); - handleInput({ - invalidValue, - key: 'username', - processValue: (value) => value.trim(), - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('passwordDescription'), - name: translate('password'), - render: (setting) => { - setting.addComponent((element) => - new SecretComponent(app, element) - .setValue(settings.password) - .onChange((password) => { - settings.password = password; - void saveSettings(); - }), - ); - }, - }, - { - desc: translate('baseDirectoryDescription'), - name: translate('baseDirectory'), - render: (setting) => { - setting.addText((text) => { - text.setPlaceholder(translate('baseDirectoryPlaceholder')).setValue( - settings.baseDirectory, - ); - handleInput({ - invalidValue, - key: 'baseDirectory', - processValue: (original) => normalizeBaseDir(original.trim()), - saveSettings, - settings, - text, - }); - }); - }, - }, - { - desc: translate('depthInfinityDescription'), - name: translate('depthInfinity'), - render: (setting) => { - setting.addToggle((toggle) => - toggle.setValue(settings.depthInfinity).onChange((value) => { - settings.depthInfinity = value; - void saveSettings(); - }), - ); - }, - }, - { - desc: translate('chunkedUploadDescription'), - name: translate('chunkedUpload'), - render: (setting) => { - setting.addToggle((toggle) => - toggle.setValue(settings.chunkedUpload).onChange((value) => { - settings.chunkedUpload = value; - void saveSettings(); - }), - ); - }, - }, - ]; + ), + }; } From ac509bed9a00d7b792b35811b8dac8b51ea536f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?He=CC=84sperus?= Date: Wed, 19 Aug 2026 15:18:01 +0800 Subject: [PATCH 3/3] refactor(list): convert modal lists to sub-pages --- .oxlintrc.json | 3 +- bun.lock | 7 +- package.json | 1 + packages/encryption/src/setting.ts | 2 +- packages/i18n/src/ru/translations.ts | 17 +- packages/i18n/src/zh-TW/translations.ts | 16 +- packages/i18n/src/zh/translations.ts | 16 +- packages/plugin/dist/dev.spec.d.ts | 2 +- ...epZ.spec.d.ts => index-CMwc1aD7.spec.d.ts} | 70 +++---- packages/plugin/dist/index.spec.d.ts | 2 +- .../src/components/FilterEditorModal.ts | 122 ----------- .../src/components/HeadersEditorModal.ts | 134 ------------- .../src/components/SourceEditorModal.ts | 123 ------------ packages/plugin/src/en.ts | 19 +- packages/plugin/src/fs/vault/request.ts | 3 +- packages/plugin/src/global.css | 22 ++ packages/plugin/src/index.ts | 25 ++- packages/plugin/src/modules/Bootstrap.ts | 2 - packages/plugin/src/settings/controls.ts | 6 - packages/plugin/src/settings/development.ts | 110 +++++----- packages/plugin/src/settings/features.ts | 5 - packages/plugin/src/settings/filter.ts | 174 +++++++++++----- packages/plugin/src/settings/miscellaneous.ts | 120 +++++++++-- .../plugin/src/settings/module-management.ts | 4 - packages/plugin/src/settings/utils.ts | 189 +++++++++++++++--- packages/plugin/src/utils/glob-match.ts | 57 +++--- packages/plugin/test/fs-vault.test.ts | 2 +- packages/plugin/test/glob-match.test.ts | 13 +- packages/s3/src/setting.ts | 2 +- packages/webdav/src/setting.ts | 2 +- tsconfig.json | 2 +- 31 files changed, 613 insertions(+), 659 deletions(-) rename packages/plugin/dist/{index-BvcV-epZ.spec.d.ts => index-CMwc1aD7.spec.d.ts} (97%) delete mode 100644 packages/plugin/src/components/FilterEditorModal.ts delete mode 100644 packages/plugin/src/components/HeadersEditorModal.ts delete mode 100644 packages/plugin/src/components/SourceEditorModal.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 370914dc..c927ecd6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -135,7 +135,8 @@ "process": "readonly", "activeWindow": "readonly", "createFragment": "readonly", - "createEl": "readonly" + "createEl": "readonly", + "createDiv": "readonly" }, "overrides": [ { diff --git a/bun.lock b/bun.lock index 4241b7a2..517cf2e1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "sync-engine", "devDependencies": { + "@obsidian-typings/obsidian-public-latest": "^6.32.0", "@tsdown/css": "^0.22.14", "@types/bun": "^1.3.14", "obsidian": "^1.13.1", @@ -51,7 +52,7 @@ }, "packages/plugin": { "name": "@hesprs/sync-engine-sdk", - "version": "3.0.5", + "version": "3.0.6", "dependencies": { "obsidian": "^1.13.1", }, @@ -183,6 +184,10 @@ "@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="], + "@obsidian-typings/obsidian-public-1.13.7": ["@obsidian-typings/obsidian-public-1.13.7@1.1.0", "", { "peerDependencies": { "@types/node": ">=16.0.0" } }, "sha512-laF2gpvOjiJDMoDiSbiXDJyNjE8aZExWgEDLScU12bVRPCVl/N6g8NKp4oa4O3IiZsOMO7x/K+dVopXHXevtug=="], + + "@obsidian-typings/obsidian-public-latest": ["@obsidian-typings/obsidian-public-latest@6.32.0", "", { "dependencies": { "@obsidian-typings/obsidian-public-1.13.7": "^1.1.0" } }, "sha512-1prfi8ipUt4TATr1Sj//+n2yydpoOwcfuURQzd/a2GDyRt4qsGh7DM5khWnRmOAzWxKtvAH280pbD/lVqVdUgA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.131.0", "", { "os": "android", "cpu": "arm" }, "sha512-t2xicr9pfzkSRYx5aPqZqlLaayIwJTqgQ81Jor31Xep2nGyL2Aq3d0K5wOfeR7VevaSdxaS9dzSQP9xDwn8fDg=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.131.0", "", { "os": "android", "cpu": "arm64" }, "sha512-nlGIod6gw75x1aEDgLS+srj+JRGY0HHm9MI9YgzE/B64l6d6+H3MSP9NOgp0+HTg8tp4vV9rVfgQGgd+TfVZcA=="], diff --git a/package.json b/package.json index a107e1d8..43030f58 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "tests": "bun --bun turbo run tests" }, "devDependencies": { + "@obsidian-typings/obsidian-public-latest": "^6.32.0", "@tsdown/css": "^0.22.14", "@types/bun": "^1.3.14", "obsidian": "^1.13.1", diff --git a/packages/encryption/src/setting.ts b/packages/encryption/src/setting.ts index 00a96e4d..4fcc7d07 100644 --- a/packages/encryption/src/setting.ts +++ b/packages/encryption/src/setting.ts @@ -38,7 +38,7 @@ export default function encryptionSetting( new SecretComponent(app, element) .setValue(settings.password) .onChange((value) => { - settings.password = value; + settings.password = value ?? ''; void saveSettings(); }), ) diff --git a/packages/i18n/src/ru/translations.ts b/packages/i18n/src/ru/translations.ts index 135ac067..50f3f1f6 100644 --- a/packages/i18n/src/ru/translations.ts +++ b/packages/i18n/src/ru/translations.ts @@ -1,9 +1,12 @@ import type { Translations } from '@hesprs/sync-engine-sdk'; const ru: Translations = { - add: 'Добавить', + addExclusionRule: 'Добавить правило исключения', + addHeader: 'Добавить заголовок', + addInclusionRule: 'Добавить правило включения', addRecord: 'Добавить запись', addSecretHeader: 'Добавить секретный заголовок', + addSource: 'Добавить источник', asymmetricStorage: 'Асимметричное хранилище', asymmetricStorageDescription: (frag) => { frag.appendText('Используйте '); @@ -55,6 +58,7 @@ const ru: Translations = { bidirectional: 'Двунаправленная', cancel: 'Отмена', cancelled: 'Отменено', + caseSensitive: 'С учётом регистра', checkConnection: 'Проверить соединение', checkConnectionFailed: 'Ошибка проверки соединения', checkConnectionSuccess: 'Соединение успешно проверено', @@ -107,9 +111,7 @@ const ru: Translations = { download: 'Скачать', downloadModule: 'Скачать модуль', edit: 'Редактировать', - editHeaders: 'Редактировать заголовки', editModuleInformation: 'Редактировать информацию о модуле', - editSources: 'Редактировать источники', enable: 'Включить', enableModule: 'Включить модуль', exclusionRules: 'Правила исключения', @@ -145,7 +147,6 @@ const ru: Translations = { headerKeyPlaceholder: 'Ключ заголовка', headerValuePlaceholder: 'Значение заголовка', hide: 'Скрыть', - httpInsecureWarning: 'Пожалуйста, избегайте использования незащищённого протокола HTTP.', icon: 'Иконка', iconDescription: (frag) => { frag.appendText( @@ -226,15 +227,17 @@ const ru: Translations = { moveRemote: 'Переместить удалённый файл', name: 'Название', namePlaceholder: 'Введите отображаемое имя модуля', + noHeaderConfigured: 'Заголовок не настроен.', noInstalledModulesFound: 'Установленные модули не найдены.', noMatchingModulesFound: 'Подходящие модули не найдены.', noModulesAvailable: 'Нет доступных модулей.', + noRuleConfigured: 'Правило не настроено.', + noSourceConfigured: 'Источник не настроен.', none: 'Нет', noticeStatusOnMobile: 'Уведомления о статусе на мобильных устройствах', noticeStatusOnMobileDescription: 'Отображать всплывающее уведомление на мобильных устройствах во время синхронизации. Заменяет строку состояния, используемую на ПК.', official: 'Официальный', - omittedInvalidEntry: 'Пропущено недействительных записей: {{count}}.', realtimeSync: 'Синхронизация в реальном времени', realtimeSyncDescription: 'Запускать синхронизацию автоматически сразу после изменения файлов. Измените задержку между изменением файла и запуском синхронизации в поле ниже.', @@ -244,7 +247,6 @@ const ru: Translations = { realtimeSyncPlaceholder: 'Введите задержку (например, 500ms, 5s)', recordsCleared: 'Записи очищены', remoteMigration: 'Миграция удалённого хранилища', - remove: 'Удалить', removeLocal: 'Удалить локальный файл', removeRecord: 'Удалить запись', removeRemote: 'Удалить удалённый файл', @@ -262,8 +264,6 @@ const ru: Translations = { skip: 'Пропустить', someModulesHidden: 'Некоторые модули скрыты, поскольку плагин Sync Engine устарел. Обновите его, чтобы просмотреть полный каталог модулей.', - sourcesDescription: - 'Добавьте URL-адреса источников модулей. Пустые и недействительные строки будут пропущены при сохранении.', startMigration: 'Начать миграцию', startNonInteractiveSync: 'Запустить фоновую синхронизацию', startSync: 'Запустить синхронизацию', @@ -324,6 +324,7 @@ const ru: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: 'Загрузить', walkingRemote: 'Сканирование удалённых файлов', + xConfigured: 'Настроено: {{x}}', xEnabled: 'Включено модулей: {{x}}', }; diff --git a/packages/i18n/src/zh-TW/translations.ts b/packages/i18n/src/zh-TW/translations.ts index c7824edc..3ec3a79e 100644 --- a/packages/i18n/src/zh-TW/translations.ts +++ b/packages/i18n/src/zh-TW/translations.ts @@ -1,9 +1,12 @@ import type { Translations } from '@hesprs/sync-engine-sdk'; const zhTW: Translations = { - add: '新增', + addExclusionRule: '新增排除規則', + addHeader: '新增標頭', + addInclusionRule: '新增包含規則', addRecord: '新增紀錄', addSecretHeader: '新增加密標頭', + addSource: '新增來源', asymmetricStorage: '非對稱儲存', asymmetricStorageDescription: (frag) => { frag.appendText('使用'); @@ -55,6 +58,7 @@ const zhTW: Translations = { bidirectional: '雙向同步', cancel: '取消', cancelled: '已取消', + caseSensitive: '區分大小寫', checkConnection: '測試連線', checkConnectionFailed: '連線測試失敗', checkConnectionSuccess: '連線測試成功', @@ -105,9 +109,7 @@ const zhTW: Translations = { download: '下載', downloadModule: '下載模組', edit: '編輯', - editHeaders: '編輯標頭', editModuleInformation: '編輯模組資訊', - editSources: '編輯來源', enable: '啟用', enableModule: '啟用模組', exclusionRules: '排除規則', @@ -142,7 +144,6 @@ const zhTW: Translations = { headerKeyPlaceholder: '標頭名稱', headerValuePlaceholder: '標頭數值', hide: '隱藏', - httpInsecureWarning: '請避免使用不安全的 HTTP 協定。', icon: '圖示', iconDescription: (frag) => { frag.appendText('設定此模組顯示於模組管理面板中的圖示,完整圖示清單可參考 '); @@ -218,14 +219,16 @@ const zhTW: Translations = { moveRemote: '移動遠端', name: '名稱', namePlaceholder: '輸入模組顯示名稱', + noHeaderConfigured: '尚未設定標頭。', noInstalledModulesFound: '未找到已安裝的模組。', noMatchingModulesFound: '未找到符合條件的模組。', noModulesAvailable: '無可用模組。', + noRuleConfigured: '尚未設定規則。', + noSourceConfigured: '尚未設定來源。', none: '無', noticeStatusOnMobile: '行動裝置同步狀態通知', noticeStatusOnMobileDescription: '同步進行時於行動裝置上顯示通知訊息(取代桌面版的狀態列)。', official: '官方', - omittedInvalidEntry: '已忽略 {{count}} 項無效條目。', realtimeSync: '即時同步', realtimeSyncDescription: '當檔案經修改後立即自動觸發同步。請在欄位中修改檔案變更到觸發同步之間的延遲時間。', @@ -235,7 +238,6 @@ const zhTW: Translations = { realtimeSyncPlaceholder: '輸入同步延遲(例如 500ms, 5s)', recordsCleared: '紀錄已清除', remoteMigration: '遠端遷移', - remove: '移除', removeLocal: '移除本地', removeRecord: '移除紀錄', removeRemote: '移除遠端', @@ -252,7 +254,6 @@ const zhTW: Translations = { skip: '跳過', someModulesHidden: '由於 Sync Engine 外掛程式版本過舊,部分模組已隱藏。請更新外掛程式以查看完整模組目錄。', - sourcesDescription: '新增模組來源 URL。儲存時將自動忽略空白與無效的資料列。', startMigration: '開始遷移', startNonInteractiveSync: '啟動非互動式同步', startSync: '開始同步', @@ -309,6 +310,7 @@ const zhTW: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: '上傳', walkingRemote: '正在掃描遠端檔案', + xConfigured: '已設定 {{x}} 項', xEnabled: '已啟用 {{x}} 個模組', }; diff --git a/packages/i18n/src/zh/translations.ts b/packages/i18n/src/zh/translations.ts index d6a2f95f..2156ff8e 100644 --- a/packages/i18n/src/zh/translations.ts +++ b/packages/i18n/src/zh/translations.ts @@ -1,9 +1,12 @@ import type { Translations } from '@hesprs/sync-engine-sdk'; const zh: Translations = { - add: '添加', + addExclusionRule: '添加排除规则', + addHeader: '添加请求头', + addInclusionRule: '添加包含规则', addRecord: '添加记录', addSecretHeader: '添加机密请求头', + addSource: '添加源', asymmetricStorage: '非对称存储', asymmetricStorageDescription: (frag) => { frag.appendText('使用 '); @@ -43,6 +46,7 @@ const zh: Translations = { bidirectional: '双向同步', cancel: '取消', cancelled: '已取消', + caseSensitive: '区分大小写', checkConnection: '测试连接', checkConnectionFailed: '测试连接失败', checkConnectionSuccess: '测试连接成功', @@ -93,9 +97,7 @@ const zh: Translations = { download: '下载', downloadModule: '下载模块', edit: '编辑', - editHeaders: '编辑请求头', editModuleInformation: '编辑模块信息', - editSources: '编辑源', enable: '启用', enableModule: '启用模块', exclusionRules: '排除规则', @@ -130,7 +132,6 @@ const zh: Translations = { headerKeyPlaceholder: '请求头键', headerValuePlaceholder: '请求头值', hide: '隐藏', - httpInsecureWarning: '请避免使用不安全的 HTTP 协议。', icon: '图标', iconDescription: (frag) => { frag.appendText('设置此模块在模块管理面板中显示的图标,完整图标可在 '); @@ -204,15 +205,17 @@ const zh: Translations = { moveRemote: '移动远程', name: '名称', namePlaceholder: '输入模块显示名称', + noHeaderConfigured: '未配置请求头。', noInstalledModulesFound: '未找到已安装的模块。', noMatchingModulesFound: '未找到匹配的模块。', noModulesAvailable: '没有可用模块。', + noRuleConfigured: '未配置规则。', + noSourceConfigured: '未配置源。', none: '无', noticeStatusOnMobile: '移动端同步状态提示', noticeStatusOnMobileDescription: '同步进行时在移动设备上显示通知提示。在桌面端则会替换状态栏显示。', official: '官方', - omittedInvalidEntry: '已忽略 {{count}} 条无效条目。', realtimeSync: '实时同步', realtimeSyncDescription: '文件一旦修改即刻自动触发同步。在输入框中修改文件修改到触发同步之间的延迟时间。', @@ -222,7 +225,6 @@ const zh: Translations = { realtimeSyncPlaceholder: '输入同步延迟(例如 500ms, 5s)', recordsCleared: '记录已清除', remoteMigration: '远程迁移', - remove: '移除', removeLocal: '移除本地', removeRecord: '移除记录', removeRemote: '移除远程', @@ -239,7 +241,6 @@ const zh: Translations = { skip: '跳过', someModulesHidden: '由于 Sync Engine 插件版本过旧,部分模块已隐藏。请更新插件以查看完整模块目录。', - sourcesDescription: '添加模块源 URL。保存时将忽略空白行和无效行。', startMigration: '开始迁移', startNonInteractiveSync: '开始静默同步', startSync: '开始同步', @@ -295,6 +296,7 @@ const zh: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: '上传', walkingRemote: '正在探测远程文件', + xConfigured: '已配置 {{x}} 项', xEnabled: '已启用 {{x}} 个模块', }; diff --git a/packages/plugin/dist/dev.spec.d.ts b/packages/plugin/dist/dev.spec.d.ts index fae82550..5a409c7a 100644 --- a/packages/plugin/dist/dev.spec.d.ts +++ b/packages/plugin/dist/dev.spec.d.ts @@ -1,4 +1,4 @@ -import { At as RecordStatsMap, Et as FolderStat, F as Decider, Mt as StatsMap, St as WrappedFs, Tt as FileStat, Z as TaskNames, jt as Stat, kt as RecordStat, m as RequestParam, p as Request, pt as Fs, wt as Binary, xt as RootFs } from "./index-BvcV-epZ.spec.js"; +import { At as RecordStatsMap, Et as FolderStat, F as Decider, Mt as StatsMap, St as WrappedFs, Tt as FileStat, Z as TaskNames, jt as Stat, kt as RecordStat, m as RequestParam, p as Request, pt as Fs, wt as Binary, xt as RootFs } from "./index-CMwc1aD7.spec.js"; //#region src/sdk/debug-wrapper.d.ts declare function debugWrapper(original: Fs, log: (content: string) => void): WrappedFs; //#endregion diff --git a/packages/plugin/dist/index-BvcV-epZ.spec.d.ts b/packages/plugin/dist/index-CMwc1aD7.spec.d.ts similarity index 97% rename from packages/plugin/dist/index-BvcV-epZ.spec.d.ts rename to packages/plugin/dist/index-CMwc1aD7.spec.d.ts index 0f06ef6c..1a0c2d01 100644 --- a/packages/plugin/dist/index-BvcV-epZ.spec.d.ts +++ b/packages/plugin/dist/index-CMwc1aD7.spec.d.ts @@ -587,20 +587,6 @@ type FileTreeTranslations = { selectAll: string; }; //#endregion -//#region src/components/HeadersEditorModal.d.ts -type HeadersEditorTranslations = { - add: string; - cancel: string; - addSecretHeader: string; - remove: string; - save: string; - customHeadersDescription: string; - editHeaders: string; - headerKeyPlaceholder: string; - headerValuePlaceholder: string; - omittedInvalidEntry: string; -}; -//#endregion //#region src/components/MigrationModal.d.ts type MigrationModalTranslations = { cancel: string; @@ -795,20 +781,6 @@ type ControlsSettingTranslations = { maxMemoryConsumption: string; maxMemoryConsumptionDescription: string; maxMemoryConsumptionPlaceholder: string; - invalidValue: string; -}; -//#endregion -//#region src/components/SourceEditorModal.d.ts -type SourceEditorTranslations = { - add: string; - cancel: string; - editSources: string; - omittedInvalidEntry: string; - moduleSourcePlaceholder: string; - remove: string; - save: string; - sourcesDescription: string; - httpInsecureWarning: string; }; //#endregion //#region src/settings/development.d.ts @@ -825,7 +797,11 @@ type DevelopmentSettingTranslations = { moduleSources: string; moduleSourcesDescription: string; edit: string; -} & SourceEditorTranslations; + xConfigured: string; + addSource: string; + noSourceConfigured: string; + moduleSourcePlaceholder: string; +}; //#endregion //#region src/settings/features.d.ts type FeaturesSettingTranslations = { @@ -844,28 +820,23 @@ type FeaturesSettingTranslations = { asymmetricStorage: string; asymmetricStorageDescription: Fragment; asymmetricStorageMigration: Fragment<'enable' | 'disable'>; - invalidValue: string; } & MigrationModalTranslations; //#endregion -//#region src/components/FilterEditorModal.d.ts -type FilterEditorTranslations = { - cancel: string; - remove: string; - save: string; - add: string; +//#region src/settings/filter.d.ts +type FilterSettingTranslations = { + filterRules: string; inclusionRules: string; - exclusionRules: string; inclusionRulesDescription: Fragment; + exclusionRules: string; exclusionRulesDescription: Fragment; + xConfigured: string; + addInclusionRule: string; + addExclusionRule: string; + noRuleConfigured: string; filterPlaceholder: string; + caseSensitive: string; }; //#endregion -//#region src/settings/filter.d.ts -type FilterSettingTranslations = { - filterRules: string; - edit: string; -} & FilterEditorTranslations; -//#endregion //#region src/settings/head.d.ts type HeadSettingTranslations = { moduleAutoUpdate: string; @@ -900,6 +871,12 @@ type MiscellaneousSettingTranslations = { customHeaders: string; customHeadersDescription: string; edit: string; + xConfigured: string; + addHeader: string; + noHeaderConfigured: string; + headerKeyPlaceholder: string; + headerValuePlaceholder: string; + addSecretHeader: string; }; //#endregion //#region src/components/module-management/index.d.ts @@ -960,7 +937,7 @@ declare class Bootstrap { keepRemote: string; renameAndKeepBoth: string; skip: string; - } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations & ModulesTranslations; + } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations & ModulesTranslations; readonly settings: { maxMemoryConsumption: TogglableValue; maxRequestConcurrency: TogglableValue; @@ -1041,7 +1018,10 @@ declare class ProgressModal extends Modal { private readonly hideDetails; onOpen(): void; root: { - hideProgress: () => void; + hideProgress: { + (): void; + (): void; + }; showProgress: () => void; }; onClose(): void; diff --git a/packages/plugin/dist/index.spec.d.ts b/packages/plugin/dist/index.spec.d.ts index c24c3eb9..9817eacf 100644 --- a/packages/plugin/dist/index.spec.d.ts +++ b/packages/plugin/dist/index.spec.d.ts @@ -1,2 +1,2 @@ -import { $ as Fragment, A as writeWithValue, At as RecordStatsMap, B as RemoveRemote, C as AugmentedModuleMeta, Ct as WriteAtom, D as s, Dt as MaybePromise, E as digOriginal, Et as FolderStat, F as Decider, G as Download, H as RemoveLocal, I as DeciderInput, J as BaseTask, K as CreateRemoteDir, L as TaskFactory, M as setNeedMigration, Mt as StatsMap, N as SyncTerminateReason, O as pipe, Ot as Progress, P as CreateLocalDir, Q as RecordStore, R as Upload, S as ExistingMemoryDB, St as WrappedFs, T as SelectFromContext, Tt as FileStat, U as MoveRemote, V as RemoveRecord, W as MoveLocal, X as ConflictResolverPayload, Y as ConflictResolver, Z as TaskNames, _ as SettingTree, _t as MoveAtom, a as DeciderEntry, at as DatabaseAsync, b as Settings, bt as OutputAtom, c as OptimizerEntry, ct as StoreOperations, d as RemoteListerEntry, dt as CustomAtom, et as ObsidianLanguageCode, f as RemoteRequestMiddlewareEntry, ft as DeleteAtom, g as SettingEntry, gt as MkdirAtom, h as RequestResponse, ht as ListReporter, i as ConflictResolverEntry, it as On, j as prefixWrapper, jt as Stat, k as readWithSize, kt as RecordStat, l as RemoteFsEntry, lt as StoreSync, m as RequestParam, mt as InputAtom, n as CallableOrObjectTree, nt as TranslationResource, o as FsWrapperEntry, ot as DatabaseSync, p as Request, pt as Fs, q as AddRecord, r as CheckConnectionResult, rt as Dispatch, s as LocalRequestMiddlewareEntry, st as StoreAsync, t as VaultRequest, tt as Translate, u as RemoteLister, ut as BatchOptimizer, v as Context, vt as OptimizerInput, w as ModuleMeta, wt as Binary, x as Translations, xt as RootFs, y as Events, yt as OptimizerOutput, z as ResolveConflict } from "./index-BvcV-epZ.spec.js"; +import { $ as Fragment, A as writeWithValue, At as RecordStatsMap, B as RemoveRemote, C as AugmentedModuleMeta, Ct as WriteAtom, D as s, Dt as MaybePromise, E as digOriginal, Et as FolderStat, F as Decider, G as Download, H as RemoveLocal, I as DeciderInput, J as BaseTask, K as CreateRemoteDir, L as TaskFactory, M as setNeedMigration, Mt as StatsMap, N as SyncTerminateReason, O as pipe, Ot as Progress, P as CreateLocalDir, Q as RecordStore, R as Upload, S as ExistingMemoryDB, St as WrappedFs, T as SelectFromContext, Tt as FileStat, U as MoveRemote, V as RemoveRecord, W as MoveLocal, X as ConflictResolverPayload, Y as ConflictResolver, Z as TaskNames, _ as SettingTree, _t as MoveAtom, a as DeciderEntry, at as DatabaseAsync, b as Settings, bt as OutputAtom, c as OptimizerEntry, ct as StoreOperations, d as RemoteListerEntry, dt as CustomAtom, et as ObsidianLanguageCode, f as RemoteRequestMiddlewareEntry, ft as DeleteAtom, g as SettingEntry, gt as MkdirAtom, h as RequestResponse, ht as ListReporter, i as ConflictResolverEntry, it as On, j as prefixWrapper, jt as Stat, k as readWithSize, kt as RecordStat, l as RemoteFsEntry, lt as StoreSync, m as RequestParam, mt as InputAtom, n as CallableOrObjectTree, nt as TranslationResource, o as FsWrapperEntry, ot as DatabaseSync, p as Request, pt as Fs, q as AddRecord, r as CheckConnectionResult, rt as Dispatch, s as LocalRequestMiddlewareEntry, st as StoreAsync, t as VaultRequest, tt as Translate, u as RemoteLister, ut as BatchOptimizer, v as Context, vt as OptimizerInput, w as ModuleMeta, wt as Binary, x as Translations, xt as RootFs, y as Events, yt as OptimizerOutput, z as ResolveConflict } from "./index-CMwc1aD7.spec.js"; export { type AddRecord, type AugmentedModuleMeta, type BaseTask, type BatchOptimizer, type Binary, type CallableOrObjectTree, type CheckConnectionResult, type ConflictResolver, type ConflictResolverEntry, type ConflictResolverPayload, type Context, type CreateLocalDir, type CreateRemoteDir, type CustomAtom, type DatabaseAsync, type DatabaseSync, type Decider, type DeciderEntry, type DeciderInput, type DeleteAtom, type Dispatch, type Download, type Events, type ExistingMemoryDB, type FileStat, type FolderStat, type Fragment, type Fs, type FsWrapperEntry, type InputAtom, type ListReporter, type LocalRequestMiddlewareEntry, type MaybePromise, type MkdirAtom, type ModuleMeta, type MoveAtom, type MoveLocal, type MoveRemote, type ObsidianLanguageCode, type On, type OptimizerEntry, type OptimizerInput, type OptimizerOutput, type OutputAtom, type Progress, type RecordStat, type RecordStatsMap, type RecordStore, type RemoteFsEntry, type RemoteLister, type RemoteListerEntry, type RemoteRequestMiddlewareEntry, type RemoveLocal, type RemoveRecord, type RemoveRemote, type Request, type RequestParam, type RequestResponse, type ResolveConflict, type RootFs, SelectFromContext, type SettingEntry, type SettingTree, type Settings, type Stat, type StatsMap, type StoreAsync, type StoreOperations, type StoreSync, type SyncTerminateReason, type TaskFactory, type TaskNames, type Translate, type TranslationResource, type Translations, type Upload, type VaultRequest, type WrappedFs, type WriteAtom, digOriginal, pipe, prefixWrapper, readWithSize, s, setNeedMigration, writeWithValue }; \ No newline at end of file diff --git a/packages/plugin/src/components/FilterEditorModal.ts b/packages/plugin/src/components/FilterEditorModal.ts deleted file mode 100644 index eb797241..00000000 --- a/packages/plugin/src/components/FilterEditorModal.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { App, Modal, setIcon, Setting, setTooltip } from 'obsidian'; -import type { Fragment, Translate } from '@/modules/I18n'; -import type { GlobMatchRule } from '@/types'; - -type FilterType = 'include' | 'exclude'; - -export type FilterEditorTranslations = { - cancel: string; - remove: string; - save: string; - add: string; - inclusionRules: string; - exclusionRules: string; - inclusionRulesDescription: Fragment; - exclusionRulesDescription: Fragment; - filterPlaceholder: string; -}; - -export default class FilterEditorModal extends Modal { - private readonly filters: Array; - private readonly t: Translate; - - constructor( - private readonly onSave: (filters: Array) => void, - private readonly filterType: FilterType, - ctx: { - app: App; - translate: Translate; - }, - filters: Array = [], - ) { - super(ctx.app); - this.contentEl.addClass('markdown-rendered'); - this.filters = structuredClone(filters); - this.t = ctx.translate; - } - - onOpen() { - const { contentEl, filterType, t, filters } = this; - contentEl.empty(); - - const titleKey = filterType === 'include' ? 'inclusionRules' : 'exclusionRules'; - const descKey = - filterType === 'include' ? 'inclusionRulesDescription' : 'exclusionRulesDescription'; - - this.setTitle(t(titleKey)); - contentEl.createEl('p', { cls: 'setting-item-description', text: t(descKey) }); - - const listContainer = contentEl.createDiv('flex flex-col gap-2 pb-2'); - const updateList = () => { - listContainer.empty(); - filters.forEach((filter, index) => { - const itemContainer = listContainer.createDiv('flex gap-2'); - const input = itemContainer.createEl('input', { - cls: 'flex-1', - placeholder: t('filterPlaceholder'), - type: 'text', - value: filter.expr, - }); - input.spellcheck = false; - input.addEventListener('input', () => { - filter.expr = input.value; - filters[index] = filter; - }); - const forceCaseBtn = itemContainer.createEl( - 'button', - 'clickable-icon aspect-square', - ); - setIcon(forceCaseBtn, 'case-sensitive'); - function updateButtonStatus() { - const activeClasses = [ - 'bg-[--interactive-accent]!', - 'color-[--text-on-accent]!', - ]; - if (filter.caseSensitive) forceCaseBtn.addClasses(activeClasses); - else forceCaseBtn.removeClasses(activeClasses); - } - updateButtonStatus(); - forceCaseBtn.onClickEvent(() => { - filter.caseSensitive = !filter.caseSensitive; - updateButtonStatus(); - }); - const trash = itemContainer.createEl( - 'button', - 'clickable-icon aspect-square color-[--color-red]', - ); - setIcon(trash, 'trash-2'); - trash.onClickEvent(() => { - filters.splice(index, 1); - updateList(); - }); - }); - }; - updateList(); - const add = contentEl.createEl('button', 'clickable-icon aspect-square ml-auto mb-2'); - setIcon(add, 'plus'); - setTooltip(add, t('add')); - add.onClickEvent(() => { - filters.push({ caseSensitive: false, expr: '' }); - updateList(); - }); - - new Setting(contentEl) - .addButton((button) => { - button.setButtonText(t('cancel')).onClick(this.close.bind(this)); - }) - .addButton((button) => { - button - .setButtonText(t('save')) - .setCta() - .onClick(() => { - this.onSave(filters); - this.close(); - }); - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} diff --git a/packages/plugin/src/components/HeadersEditorModal.ts b/packages/plugin/src/components/HeadersEditorModal.ts deleted file mode 100644 index 366a3be9..00000000 --- a/packages/plugin/src/components/HeadersEditorModal.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { App, Modal, Notice, SecretComponent, setIcon, Setting, setTooltip } from 'obsidian'; -import type { CustomHeaders } from '@/modules/Bootstrap'; -import type { Translate } from '@/modules/I18n'; - -export type HeadersEditorTranslations = { - add: string; - cancel: string; - addSecretHeader: string; - remove: string; - save: string; - customHeadersDescription: string; - editHeaders: string; - headerKeyPlaceholder: string; - headerValuePlaceholder: string; - omittedInvalidEntry: string; -}; - -export default class HeadersEditorModal extends Modal { - private readonly headers: CustomHeaders; - - constructor( - private readonly onSave: (headers: CustomHeaders) => void, - private readonly ctx: { - app: App; - translate: Translate; - }, - headers: CustomHeaders, - ) { - super(ctx.app); - this.headers = structuredClone(headers); - } - - onOpen() { - const { - contentEl, - headers, - ctx: { translate: t, app }, - } = this; - contentEl.empty(); - - this.setTitle(t('editHeaders')); - contentEl.createEl('p', { - cls: 'setting-item-description', - text: t('customHeadersDescription'), - }); - - const listContainer = contentEl.createDiv('flex flex-col gap-2 pb-2'); - - const updateList = () => { - listContainer.empty(); - headers.forEach((header, index) => { - const { key, type, value } = header; - const itemContainer = listContainer.createDiv('flex gap-2 items-center'); - const headerKey = itemContainer.createEl('input', { - cls: 'flex-1', - placeholder: t('headerKeyPlaceholder'), - type: 'text', - value: key, - }); - headerKey.spellcheck = false; - headerKey.addEventListener('input', () => (header.key = headerKey.value)); - if (type === 'plaintext') { - const headerValue = itemContainer.createEl('input', { - cls: 'flex-1', - placeholder: t('headerValuePlaceholder'), - type: 'text', - value, - }); - headerValue.spellcheck = false; - headerValue.addEventListener('input', () => (header.value = headerValue.value)); - } else - new SecretComponent(app, itemContainer) - .setValue(value) - .onChange((val) => (header.value = val)); - - const trash = itemContainer.createEl( - 'button', - 'clickable-icon aspect-square color-[--color-red]', - ); - setIcon(trash, 'trash-2'); - trash.onClickEvent(() => { - headers.splice(index, 1); - updateList(); - }); - }); - }; - updateList(); - const addRow = contentEl.createDiv('mb-2 flex justify-end gap-1'); - const addSecret = addRow.createEl('button', 'clickable-icon aspect-square'); - setIcon(addSecret, 'key-round'); - setTooltip(addSecret, t('addSecretHeader')); - const add = addRow.createEl('button', 'clickable-icon aspect-square'); - setIcon(add, 'plus'); - setTooltip(add, t('add')); - addSecret.onClickEvent(() => { - headers.push({ key: '', type: 'secret', value: '' }); - updateList(); - }); - add.onClickEvent(() => { - headers.push({ key: '', type: 'plaintext', value: '' }); - updateList(); - }); - - new Setting(contentEl) - .addButton((button) => { - button.setButtonText(t('cancel')).onClick(this.close.bind(this)); - }) - .addButton((button) => { - button - .setButtonText(t('save')) - .setCta() - .onClick(() => { - const validHeaders: CustomHeaders = []; - headers.forEach((header) => { - if (!header.key.trim()) return; - validHeaders.push(header); - }); - this.onSave(validHeaders); - if (validHeaders.length !== headers.length) - new Notice( - t('omittedInvalidEntry', { - count: headers.length - validHeaders.length, - }), - ); - this.close(); - }); - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} diff --git a/packages/plugin/src/components/SourceEditorModal.ts b/packages/plugin/src/components/SourceEditorModal.ts deleted file mode 100644 index 05d7faa1..00000000 --- a/packages/plugin/src/components/SourceEditorModal.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { normalizeUrl } from '@repo/shared/path'; -import { App, Modal, Notice, setIcon, Setting, setTooltip } from 'obsidian'; -import type { Translate } from '@/modules/I18n'; - -export type SourceEditorTranslations = { - add: string; - cancel: string; - editSources: string; - omittedInvalidEntry: string; - moduleSourcePlaceholder: string; - remove: string; - save: string; - sourcesDescription: string; - httpInsecureWarning: string; -}; - -export default class SourceEditorModal extends Modal { - private readonly sources: Array; - private readonly t: Translate; - - constructor( - private readonly onSave: (sources: Array) => void, - ctx: { - app: App; - translate: Translate; - }, - sources: Array = [], - ) { - super(ctx.app); - this.sources = structuredClone(sources); - this.t = ctx.translate; - } - - onOpen() { - const { contentEl, sources, t } = this; - contentEl.empty(); - - this.setTitle(t('editSources')); - contentEl.createEl('p', { - cls: 'setting-item-description', - text: t('sourcesDescription'), - }); - - const listContainer = contentEl.createDiv('flex flex-col gap-2 pb-2'); - - const updateList = () => { - listContainer.empty(); - sources.forEach((source, index) => { - const itemContainer = listContainer.createDiv('flex gap-2'); - const input = itemContainer.createEl('input', { - cls: 'flex-1', - placeholder: t('moduleSourcePlaceholder'), - type: 'text', - value: source, - }); - input.spellcheck = false; - input.addEventListener('input', () => (sources[index] = input.value)); - const trash = itemContainer.createEl( - 'button', - 'clickable-icon aspect-square color-[--color-red]', - ); - setIcon(trash, 'trash-2'); - trash.onClickEvent(() => { - sources.splice(index, 1); - updateList(); - }); - }); - }; - updateList(); - const add = contentEl.createEl('button', 'clickable-icon aspect-square ml-auto mb-2'); - setIcon(add, 'plus'); - setTooltip(add, t('add')); - add.onClickEvent(() => { - sources.push(''); - updateList(); - }); - - new Setting(contentEl) - .addButton((button) => { - button.setButtonText(t('cancel')).onClick(this.close.bind(this)); - }) - .addButton((button) => { - button - .setButtonText(t('save')) - .setCta() - .onClick(() => { - const validSources: Array = []; - sources.forEach((source) => { - const normalizedSource = processSource( - source, - t('httpInsecureWarning'), - ); - if (!normalizedSource) return; - validSources.push(normalizedSource); - }); - this.onSave(validSources); - if (validSources.length !== sources.length) - new Notice( - t('omittedInvalidEntry', { - count: sources.length - validSources.length, - }), - ); - this.close(); - }); - }); - } - - onClose() { - const { contentEl } = this; - contentEl.empty(); - } -} - -function processSource(source: string, warning: string): string | false { - try { - const { protocol } = new URL(source); - if (protocol === 'http:') new Notice(warning); - if (protocol !== 'http:' && protocol !== 'https:') return false; - return normalizeUrl(source); - } catch { - return false; - } -} diff --git a/packages/plugin/src/en.ts b/packages/plugin/src/en.ts index d739b965..97d5f3f2 100644 --- a/packages/plugin/src/en.ts +++ b/packages/plugin/src/en.ts @@ -1,9 +1,12 @@ import type { Translations } from '@'; const en: Translations = { - add: 'Add', + addExclusionRule: 'Add exclusion rule', + addHeader: 'Add header', + addInclusionRule: 'Add inclusion rule', addRecord: 'Add record', addSecretHeader: 'Add secret header', + addSource: 'Add source', asymmetricStorage: 'Asymmetric storage', asymmetricStorageDescription: (frag) => { frag.appendText('Use '); @@ -55,6 +58,7 @@ const en: Translations = { bidirectional: 'Bidirectional', cancel: 'Cancel', cancelled: 'Cancelled', + caseSensitive: 'Case sensitive', checkConnection: 'Check connection', checkConnectionFailed: 'Check connection failed', checkConnectionSuccess: 'Check connection succeeded', @@ -107,9 +111,7 @@ const en: Translations = { download: 'Download', downloadModule: 'Download module', edit: 'Edit', - editHeaders: 'Edit headers', editModuleInformation: 'Edit module information', - editSources: 'Edit sources', enable: 'Enable', enableModule: 'Enable module', exclusionRules: 'Exclusion rules', @@ -145,7 +147,6 @@ const en: Translations = { headerKeyPlaceholder: 'Header key', headerValuePlaceholder: 'Header value', hide: 'Hide', - httpInsecureWarning: 'Please avoid using insecure HTTP protocol.', icon: 'Icon', iconDescription: (frag) => { frag.appendText( @@ -182,7 +183,7 @@ const en: Translations = { text: 'It is strongly discouraged to turn off integrity verification, since it will expose you to a large attack surface.', }); }, - invalidValue: 'Invalid value, reverted to original.', + invalidValue: 'Invalid value!', keepLocal: 'Keep local', keepRemote: 'Keep remote', latestSurvive: 'Latest survives', @@ -226,15 +227,17 @@ const en: Translations = { moveRemote: 'Move remote', name: 'Name', namePlaceholder: 'Enter module display name', + noHeaderConfigured: 'No header configured.', noInstalledModulesFound: 'No installed modules found.', noMatchingModulesFound: 'No matching modules found.', noModulesAvailable: 'No modules available.', + noRuleConfigured: 'No rule configured.', + noSourceConfigured: 'No source configured.', none: 'None', noticeStatusOnMobile: 'Notice sync status on mobile', noticeStatusOnMobileDescription: 'Display a notice on mobile devices when synchronization is in progress. Replaces the status bar on desktop.', official: 'Official', - omittedInvalidEntry: 'Omitted {{count}} invalid entry(s).', realtimeSync: 'Realtime sync', realtimeSyncDescription: 'Trigger syncs automatically as soon as files are modified. Alter the delay between a file being modified and the sync being triggered in the field.', @@ -244,7 +247,6 @@ const en: Translations = { realtimeSyncPlaceholder: 'Enter sync delay (e.g. 500ms, 5s)', recordsCleared: 'Records cleared', remoteMigration: 'Remote migration', - remove: 'Remove', removeLocal: 'Remove local', removeRecord: 'Remove record', removeRemote: 'Remove remote', @@ -262,7 +264,6 @@ const en: Translations = { skip: 'Skip', someModulesHidden: 'Some modules are hidden since Sync Engine plugin is outdated, update to explore the full module catalog.', - sourcesDescription: 'Add module source URLs. Empty and invalid rows are omitted when saved.', startMigration: 'Start migration', startNonInteractiveSync: 'Start non-interactive sync', startSync: 'Start sync', @@ -321,8 +322,8 @@ const en: Translations = { updateSourcePlaceholder: 'https://example.com/modules.json', upload: 'Upload', walkingRemote: 'Discovering remote files', + xConfigured: '{{x}} configured', xEnabled: '{{x}} enabled', - xSources: '{{x}} sources', }; export default en; diff --git a/packages/plugin/src/fs/vault/request.ts b/packages/plugin/src/fs/vault/request.ts index f4309461..3ca5f08f 100644 --- a/packages/plugin/src/fs/vault/request.ts +++ b/packages/plugin/src/fs/vault/request.ts @@ -48,8 +48,7 @@ function toKey(vaultPath: string, isDir: boolean): string { // "system" / undefined: system // "local": local function getTrashOption(vault: Vault): 'local' | 'system' | 'permanent' { - const option = (vault as { config?: { trashOption?: 'local' | 'system' | 'none' } }).config - ?.trashOption; + const option = vault.config.trashOption; return option ? (option === 'none' ? 'permanent' : option) : 'system'; } diff --git a/packages/plugin/src/global.css b/packages/plugin/src/global.css index fd49610e..182e17c4 100644 --- a/packages/plugin/src/global.css +++ b/packages/plugin/src/global.css @@ -64,6 +64,16 @@ input[type='checkbox']:indeterminate { } } +input[type='text'].sync-engine-invalid-input { + border-color: var(--text-warning); + --background-modifier-border-focus: var(--text-warning); + background-image: var(--sync-engine-warning); + background-repeat: no-repeat; + background-position: right calc(var(--input-height) * 0.5 - 0.5rem) center; + background-size: 1rem 1rem; + padding-right: var(--input-height); +} + .sync-engine-card .flair { margin: 0; } @@ -72,3 +82,15 @@ input[type='checkbox']:indeterminate { --flair-background: var(--color-accent); --flair-color: var(--text-on-accent); } + +.sync-engine-editable-list { + .setting-item-info { + display: none; + } + .setting-item-control { + width: 100%; + input[type='text'] { + flex: 1; + } + } +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 34e4b0da..1c92f76a 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -15,6 +15,7 @@ import Registrar from './modules/Registrar'; import Scheduler from './modules/Scheduler'; import Storage from './modules/Storage'; import Sync from './modules/Sync'; +import { normalizeGlob } from './utils/glob-match'; const internalModules = [ EventBus, @@ -99,6 +100,7 @@ export default class SyncEngine extends Plugin { }; migrateGlobMatchRules(settings); + void this.saveSettings(); // https://github.com/microsoft/TypeScript/issues/62995 const preMerge = { @@ -136,15 +138,28 @@ export default class SyncEngine extends Plugin { readonly saveSettings = () => this.saveData(this.settings); } -// TODO: remove after August 20 +// TODO: remove after November 20 function migrateGlobMatchRules(settings: Settings) { const { inclusionRules, exclusionRules } = settings; - const migrateRules = (rules: Array) => - rules.forEach((rule) => { - if (!('options' in rule)) return; - rule.caseSensitive = (rule.options as { caseSensitive: boolean }).caseSensitive; + const migrateRules = (rules: Array) => { + const typedRules = rules as Array<{ + expr: string; + caseSensitive: boolean; + invalid?: true; + options?: { caseSensitive: boolean }; + }>; + typedRules.forEach((rule) => { + const normalized = normalizeGlob(rule.expr); + if (normalized) rule.expr = normalized; + else rule.invalid = true; + if (!rule.options) return; + rule.caseSensitive = rule.options.caseSensitive; delete rule.options; }); + const rulesCopy = structuredClone(typedRules); + rules.length = 0; + rules.push(...rulesCopy.filter(({ invalid }) => !invalid)); + }; migrateRules(inclusionRules); migrateRules(exclusionRules); } diff --git a/packages/plugin/src/modules/Bootstrap.ts b/packages/plugin/src/modules/Bootstrap.ts index 63cb40c3..e4fce51d 100644 --- a/packages/plugin/src/modules/Bootstrap.ts +++ b/packages/plugin/src/modules/Bootstrap.ts @@ -3,7 +3,6 @@ import type { App, SecretStorage } from 'obsidian'; import type { Ref } from 'synthkernel'; import type { DatabaseSync } from 'uni-kv'; import type { FileTreeTranslations } from '@/components/file-tree'; -import type { HeadersEditorTranslations } from '@/components/HeadersEditorModal'; import type { ModuleEditorTranslations } from '@/components/ModuleEditorModal'; import type { UnknownModuleTranslations } from '@/components/UnknownModuleModal'; import type { BatchOptimizer, Fs, MemoryControlSharedState } from '@/fs'; @@ -101,7 +100,6 @@ export default class Bootstrap { FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & - HeadersEditorTranslations & UnknownModuleTranslations & ModuleEditorTranslations & FileTreeTranslations & diff --git a/packages/plugin/src/settings/controls.ts b/packages/plugin/src/settings/controls.ts index cd0bfc2f..1c26706f 100644 --- a/packages/plugin/src/settings/controls.ts +++ b/packages/plugin/src/settings/controls.ts @@ -18,7 +18,6 @@ export type ControlsSettingTranslations = { maxMemoryConsumption: string; maxMemoryConsumptionDescription: string; maxMemoryConsumptionPlaceholder: string; - invalidValue: string; }; export default function controlsSettings({ @@ -30,7 +29,6 @@ export default function controlsSettings({ saveSettings: () => Promise; settings: Settings; }): CallableOrObjectTree { - const invalidValue = translate('invalidValue'); return { 2000: s( (self) => ({ @@ -44,7 +42,6 @@ export default function controlsSettings({ name: translate('maxFileSize'), render: renderTogglableValue({ field: settings.maxFileSize, - invalidValue, placeholder: translate('maxFileSizePlaceholder'), rejectZero: true, saveSettings, @@ -56,7 +53,6 @@ export default function controlsSettings({ name: translate('maxRequestConcurrency'), render: renderTogglableValue({ field: settings.maxRequestConcurrency, - invalidValue, placeholder: translate('maxRequestConcurrencyPlaceholder'), rejectZero: true, saveSettings, @@ -68,7 +64,6 @@ export default function controlsSettings({ name: translate('minRequestInterval'), render: renderTogglableValue({ field: settings.minRequestInterval, - invalidValue, placeholder: translate('minRequestIntervalPlaceholder'), saveSettings, type: 'time', @@ -79,7 +74,6 @@ export default function controlsSettings({ name: translate('maxMemoryConsumption'), render: renderTogglableValue({ field: settings.maxMemoryConsumption, - invalidValue, placeholder: translate('maxMemoryConsumptionPlaceholder'), rejectZero: true, saveSettings, diff --git a/packages/plugin/src/settings/development.ts b/packages/plugin/src/settings/development.ts index c5c79b2b..f3fc7db3 100644 --- a/packages/plugin/src/settings/development.ts +++ b/packages/plugin/src/settings/development.ts @@ -1,18 +1,12 @@ import type { Settings } from '@'; -import type { - App, - SettingDefinitionGroup, - SettingDefinitionItem, - SettingGroupItem, -} from 'obsidian'; -import { normalizeBaseDir } from '@repo/shared/path'; +import type { SettingGroupItem } from 'obsidian'; +import type { DatabaseSync } from 'uni-kv'; +import { normalizeBaseDir, normalizeUrl } from '@repo/shared/path'; import { Notice } from 'obsidian'; -import type { SourceEditorTranslations } from '@/components/SourceEditorModal'; import type { Translate } from '@/modules/I18n'; import type { CallableOrObjectTree } from '@/modules/Registrar'; import type { MaybePromise } from '@/sdk'; -import ModuleSourceEditorModal from '@/components/SourceEditorModal'; -import { s } from './utils'; +import { generateEditableList, reactivelyValidate, s } from './utils'; export type DevelopmentSettingTranslations = { development: string; @@ -27,9 +21,11 @@ export type DevelopmentSettingTranslations = { moduleSources: string; moduleSourcesDescription: string; edit: string; - xSources: string; + xConfigured: string; addSource: string; -} & SourceEditorTranslations; + noSourceConfigured: string; + moduleSourcePlaceholder: string; +}; export default function developmentSettings({ translate, @@ -37,14 +33,16 @@ export default function developmentSettings({ deleteRecordStore, settings, saveSettings, - app, + memoryDB, + rerenderSettingTab, }: { translate: Translate; deleteRecordStore: (namespace?: string) => MaybePromise; exportLogs: () => Promise; settings: Settings; saveSettings: () => Promise; - app: App; + memoryDB: DatabaseSync; + rerenderSettingTab: () => void; }): CallableOrObjectTree { return { 5000: s( @@ -92,52 +90,64 @@ export default function developmentSettings({ }); }, })), - 3000: s(() => ({ - desc: translate('moduleSourcesDescription'), - name: translate('moduleSources'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => - new ModuleSourceEditorModal( - (sources) => { - settings.moduleSources = sources; - void saveSettings(); - }, - { app, translate }, - settings.moduleSources, - ).open(), - ); - }); - }, - })), - 4000: s( + 3000: s( (self) => ({ desc: translate('moduleSourcesDescription'), - displayValue: translate('xSources', { x: settings.moduleSources.length }), + displayValue: () => + translate('xConfigured', { x: settings.moduleSources.length }), items: Object.values(self).map((node) => node(node)), name: translate('moduleSources'), type: 'page', }), { - 1000: s(() => ({ - addItem: { - action: () => {}, - name: translate('addSource'), - }, - items: settings.moduleSources.map(generateEditableItem), - type: 'list', - })), + 1000: s(() => + generateEditableList({ + defaultValue: '', + identifier: 'moduleSources', + items: settings.moduleSources, + memoryDB, + render: (setting, item, save) => { + setting.addText((text) => { + text.setPlaceholder( + translate('moduleSourcePlaceholder'), + ).setValue(item.value); + reactivelyValidate({ + immediate: true, + onSave: (value) => { + item.value = value; + save(); + }, + parse: (value) => { + try { + item.value = value; + const url = normalizeUrl(value); + item.valid = true; + return url; + } catch { + if (!item.valid) return; + item.valid = false; + save(); + } + }, + text, + }); + if (item.new) { + item.new = false; + text.inputEl.focus(); + } + }); + }, + rerenderSettingTab, + saveSettings, + translations: { + add: translate('addSource'), + empty: translate('noSourceConfigured'), + }, + }), + ), }, ), }, ), }; } - -function generateEditableItem(source: string): SettingGroupItem { - return { - name: '', - render: (setting) => {}, - searchable: false, - }; -} diff --git a/packages/plugin/src/settings/features.ts b/packages/plugin/src/settings/features.ts index ffcd647b..d33422b8 100644 --- a/packages/plugin/src/settings/features.ts +++ b/packages/plugin/src/settings/features.ts @@ -23,7 +23,6 @@ export type FeaturesSettingTranslations = { asymmetricStorage: string; asymmetricStorageDescription: Fragment; asymmetricStorageMigration: Fragment<'enable' | 'disable'>; - invalidValue: string; } & MigrationModalTranslations; export default function featuresSettings(ctx: { @@ -42,7 +41,6 @@ export default function featuresSettings(ctx: { settings, recordStoreExists, } = ctx; - const invalidValue = translate('invalidValue'); return { 1000: s( (self) => ({ @@ -56,7 +54,6 @@ export default function featuresSettings(ctx: { name: translate('realtimeSync'), render: renderTogglableValue({ field: settings.realtimeSync, - invalidValue, placeholder: translate('realtimeSyncPlaceholder'), saveSettings, type: 'time', @@ -67,7 +64,6 @@ export default function featuresSettings(ctx: { name: translate('startupSync'), render: renderTogglableValue({ field: settings.startupSync, - invalidValue, placeholder: translate('startupSyncPlaceholder'), saveSettings, type: 'time', @@ -78,7 +74,6 @@ export default function featuresSettings(ctx: { name: translate('scheduledSync'), render: renderTogglableValue({ field: settings.scheduledSync, - invalidValue, onChange: () => { stopScheduledSync(); startScheduledSync(); diff --git a/packages/plugin/src/settings/filter.ts b/packages/plugin/src/settings/filter.ts index 2cb2b628..60e097c7 100644 --- a/packages/plugin/src/settings/filter.ts +++ b/packages/plugin/src/settings/filter.ts @@ -1,26 +1,38 @@ import type { Settings } from '@'; -import type { App, SettingGroupItem } from 'obsidian'; -import type { FilterEditorTranslations } from '@/components/FilterEditorModal'; -import type { Translate } from '@/modules/I18n'; +import type { SettingGroupItem } from 'obsidian'; +import type { DatabaseSync } from 'uni-kv'; +import type { Fragment, Translate } from '@/modules/I18n'; import type { CallableOrObjectTree } from '@/modules/Registrar'; -import FilterEditorModal from '@/components/FilterEditorModal'; -import { s } from './utils'; +import type { GlobMatchRule } from '@/types'; +import { normalizeGlob } from '@/utils/glob-match'; +import { generateEditableList, reactivelyValidate, s } from './utils'; export type FilterSettingTranslations = { filterRules: string; - edit: string; -} & FilterEditorTranslations; + inclusionRules: string; + inclusionRulesDescription: Fragment; + exclusionRules: string; + exclusionRulesDescription: Fragment; + xConfigured: string; + addInclusionRule: string; + addExclusionRule: string; + noRuleConfigured: string; + filterPlaceholder: string; + caseSensitive: string; +}; export default function filterSettings({ translate, saveSettings, - app, settings, + memoryDB, + rerenderSettingTab, }: { translate: Translate; saveSettings: () => Promise; - app: App; settings: Settings; + memoryDB: DatabaseSync; + rerenderSettingTab: () => void; }): CallableOrObjectTree { return { 3000: s( @@ -30,45 +42,117 @@ export default function filterSettings({ type: 'group', }), { - 1000: s(() => ({ - desc: translate('inclusionRulesDescription'), - name: translate('inclusionRules'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new FilterEditorModal( - (filters) => { - settings.inclusionRules = filters; - void saveSettings(); - }, - 'include', - { app, translate }, - settings.inclusionRules, - ).open(); - }); - }); + 1000: s( + (self) => ({ + desc: translate('inclusionRulesDescription'), + displayValue: () => + translate('xConfigured', { x: settings.inclusionRules.length }), + items: Object.values(self).map((node) => node(node)), + name: translate('inclusionRules'), + type: 'page', + }), + { + 1000: s(() => + generateRuleList({ + add: translate('addInclusionRule'), + empty: translate('noRuleConfigured'), + identifier: 'inclusionRules', + items: settings.inclusionRules, + }), + ), }, - })), - 2000: s(() => ({ - desc: translate('exclusionRulesDescription'), - name: translate('exclusionRules'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new FilterEditorModal( - (filters) => { - settings.exclusionRules = filters; - void saveSettings(); - }, - 'exclude', - { app, translate }, - settings.exclusionRules, - ).open(); - }); - }); + ), + 2000: s( + (self) => ({ + desc: translate('exclusionRulesDescription'), + displayValue: () => + translate('xConfigured', { x: settings.exclusionRules.length }), + items: Object.values(self).map((node) => node(node)), + name: translate('exclusionRules'), + type: 'page', + }), + { + 1000: s(() => + generateRuleList({ + add: translate('addExclusionRule'), + empty: translate('noRuleConfigured'), + identifier: 'exclusionRules', + items: settings.exclusionRules, + }), + ), }, - })), + ), }, ), }; + + function generateRuleList({ + add, + empty, + identifier, + items, + }: { + add: string; + empty: string; + identifier: string; + items: Array; + }) { + return generateEditableList({ + defaultValue: { caseSensitive: false, expr: '' }, + identifier, + items, + memoryDB, + render: (setting, item, save) => { + setting.addText((text) => { + text.setPlaceholder(translate('filterPlaceholder')).setValue(item.value.expr); + reactivelyValidate({ + immediate: true, + onSave: (value) => { + item.value.expr = value; + save(); + }, + parse: (value) => { + item.value.expr = value; + const normalized = normalizeGlob(value); + if (!normalized) { + item.valid = false; + save(); + return; + } + item.valid = true; + return normalized; + }, + text, + }); + if (item.new) { + item.new = false; + text.inputEl.focus(); + } + }); + setting.addExtraButton((button) => { + const activeClasses = [ + 'bg-[--interactive-accent]!', + 'color-[--text-on-accent]!', + ]; + const updateStatus = () => { + if (item.value.caseSensitive) + button.extraSettingsEl.addClasses(activeClasses); + else button.extraSettingsEl.removeClasses(activeClasses); + }; + updateStatus(); + button + .setIcon('case-sensitive') + .setTooltip(translate('caseSensitive')) + .onClick(() => { + item.value.caseSensitive = !item.value.caseSensitive; + updateStatus(); + save(); + }); + }); + }, + rerenderSettingTab, + saveSettings, + translations: { add, empty }, + }); + } } diff --git a/packages/plugin/src/settings/miscellaneous.ts b/packages/plugin/src/settings/miscellaneous.ts index b3aba62d..8f72648c 100644 --- a/packages/plugin/src/settings/miscellaneous.ts +++ b/packages/plugin/src/settings/miscellaneous.ts @@ -1,9 +1,10 @@ import type { Settings } from '@'; import type { App, SettingGroupItem } from 'obsidian'; +import type { DatabaseSync } from 'uni-kv'; +import { SecretComponent } from 'obsidian'; import type { Translate } from '@/modules/I18n'; import type { CallableOrObjectTree } from '@/modules/Registrar'; -import HeadersEditorModal from '@/components/HeadersEditorModal'; -import { s } from './utils'; +import { generateEditableList, reactivelyValidate, s } from './utils'; export type MiscellaneousSettingTranslations = { miscellaneous: string; @@ -20,17 +21,27 @@ export type MiscellaneousSettingTranslations = { customHeaders: string; customHeadersDescription: string; edit: string; + xConfigured: string; + addHeader: string; + noHeaderConfigured: string; + headerKeyPlaceholder: string; + headerValuePlaceholder: string; + addSecretHeader: string; }; export default function miscellaneousSettings({ translate, saveSettings, settings, + memoryDB, + rerenderSettingTab, app, }: { translate: Translate; saveSettings: () => Promise; settings: Settings; + memoryDB: DatabaseSync; + rerenderSettingTab: () => void; app: App; }): CallableOrObjectTree { return { @@ -41,24 +52,97 @@ export default function miscellaneousSettings({ type: 'group', }), { - 1000: s(() => ({ - desc: translate('customHeadersDescription'), - name: translate('customHeaders'), - render: (setting) => { - setting.addButton((button) => { - button.setButtonText(translate('edit')).onClick(() => { - new HeadersEditorModal( - (headers) => { - settings.customHeaders = headers; - void saveSettings(); + 1000: s( + (self) => ({ + desc: translate('customHeadersDescription'), + displayValue: () => + translate('xConfigured', { x: settings.customHeaders.length }), + items: Object.values(self).map((node) => node(node)), + name: translate('customHeaders'), + type: 'page', + }), + { + 1000: s(() => + generateEditableList({ + defaultValue: { key: '', type: 'plaintext', value: '' }, + extraButtons: [ + (button, list) => { + button + .setIcon('key-round') + .setTooltip(translate('addSecretHeader')) + .onClick(() => { + list.push({ + new: true, + valid: false, + value: { key: '', type: 'secret', value: '' }, + }); + rerenderSettingTab(); + }); }, - { app, translate }, - settings.customHeaders, - ).open(); - }); - }); + ], + identifier: 'customHeaders', + items: settings.customHeaders, + memoryDB, + render: (setting, item, save) => { + setting.addText((text) => { + text.setValue(item.value.key).setPlaceholder( + translate('headerKeyPlaceholder'), + ); + reactivelyValidate({ + immediate: true, + onSave: (value) => { + item.value.key = value; + save(); + }, + parse: (value) => { + item.value.key = value; + const trimmed = value.trim(); + if (!trimmed) { + item.valid = false; + save(); + return; + } + item.valid = true; + return trimmed; + }, + text, + }); + if (item.new) { + item.new = false; + text.inputEl.focus(); + } + }); + if (item.value.type === 'plaintext') + setting.addText((text) => + text + .setValue(item.value.value) + .setPlaceholder(translate('headerValuePlaceholder')) + .inputEl.addEventListener('blur', () => { + item.value.value = text.getValue().trim(); + text.setValue(item.value.value); + save(); + }), + ); + else + setting.addComponent((element) => + new SecretComponent(app, element) + .setValue(item.value.value) + .onChange((value) => { + item.value.value = value ?? ''; + save(); + }), + ); + }, + rerenderSettingTab, + saveSettings, + translations: { + add: translate('addHeader'), + empty: translate('noHeaderConfigured'), + }, + }), + ), }, - })), + ), 2000: s(() => ({ control: { key: 'noticeStatusOnMobile', type: 'toggle' }, desc: translate('noticeStatusOnMobileDescription'), diff --git a/packages/plugin/src/settings/module-management.ts b/packages/plugin/src/settings/module-management.ts index e8fc08e5..741bf007 100644 --- a/packages/plugin/src/settings/module-management.ts +++ b/packages/plugin/src/settings/module-management.ts @@ -5,7 +5,6 @@ import type { ModuleManagementTranslations } from '@/components/module-managemen import type { AugmentedModuleMeta } from '@/modules/Extensibility'; import type { Translate } from '@/modules/I18n'; import { mountModuleManagementList } from '@/components/module-management'; -import ModuleSourceEditorModal from '@/components/SourceEditorModal'; export type ModulesTranslations = ModuleManagementTranslations & { searchModules: string; @@ -16,7 +15,6 @@ export type ModulesTranslations = ModuleManagementTranslations & { export default class ModuleManagement extends SettingPage { private readonly t: Translate; private readonly cleanup: Array<() => void> = []; - private sourceEditorModal?: ModuleSourceEditorModal; private showInstalledOnly = false; constructor( @@ -81,8 +79,6 @@ export default class ModuleManagement extends SettingPage { } hide() { - this.sourceEditorModal?.close(); - this.sourceEditorModal = undefined; this.cleanup.splice(0).forEach((fn) => fn()); this.containerEl.empty(); } diff --git a/packages/plugin/src/settings/utils.ts b/packages/plugin/src/settings/utils.ts index 26595ac0..55f166ff 100644 --- a/packages/plugin/src/settings/utils.ts +++ b/packages/plugin/src/settings/utils.ts @@ -1,10 +1,26 @@ -import type { Setting, SettingDefinitionItem } from 'obsidian'; +import type { + ExtraButtonComponent, + Setting, + SettingDefinitionItem, + SettingDefinitionList, + TextComponent, +} from 'obsidian'; +import type { DatabaseSync } from 'uni-kv'; +import { encodeURIComponent3986 } from '@repo/shared/path'; +import { setIcon } from 'obsidian'; import type { CallableOrObjectTree, SettingTree } from '@/modules/Registrar'; -import type { TogglableValue } from '@/types'; +import type { General, TogglableValue } from '@/types'; import { formatFileSize, formatTime, parseFileSize, parseTime } from '@/utils/unit-converter'; type InputType = 'number' | 'time' | 'fileSize'; -const WARNING_INTERVAL = 2000; +type EphemeralEditableItem = { + valid: boolean; + new: boolean; + value: T; +}; +type EphemeralEditableListSchema = { + ephemeralEditableLists: Array>; +}; export function s( parent: (self: SettingTree) => SettingDefinitionItem, @@ -13,6 +29,50 @@ export function s( return children ? Object.assign(parent, children) : (parent as unknown as CallableOrObjectTree); } +function setWarningIfNotExist(): void { + const name = '--sync-engine-warning'; + if (activeDocument.body.style.getPropertyValue(name)) return; + const dummy = createDiv(); + setIcon(dummy, 'triangle-alert'); + (dummy.firstElementChild as SVGSVGElement).setAttr( + 'stroke', + getComputedStyle(activeDocument.body).getPropertyValue('--text-warning'), + ); + activeDocument.body.style.setProperty( + name, + `url("data:image/svg+xml,${encodeURIComponent3986(dummy.innerHTML)}")`, + ); +} + +export function reactivelyValidate({ + text, + parse, + onSave, + format = String, + immediate = false, +}: { + text: TextComponent; + parse: (value: string) => T | undefined; + format?: (value: T) => string; + onSave: (value: T) => void; + immediate?: boolean; +}) { + setWarningIfNotExist(); + let validValue: T | undefined; + const invalid = 'sync-engine-invalid-input'; + const handleInput = (value: string) => { + validValue = parse(value); + if (validValue === undefined) text.inputEl.addClass(invalid); + else text.inputEl.removeClass(invalid); + }; + text.onChange(handleInput).inputEl.addEventListener('blur', () => { + if (validValue === undefined) return; + onSave(validValue); + text.setValue(format(validValue)); + }); + if (immediate) handleInput(text.getValue()); +} + export function renderTogglableValue({ placeholder, field, @@ -21,7 +81,6 @@ export function renderTogglableValue({ rejectZero, onChange, onToggle, - invalidValue, }: { placeholder: string; field: TogglableValue; @@ -30,36 +89,31 @@ export function renderTogglableValue({ rejectZero?: boolean; onChange?: (value: number) => void; onToggle?: (value: boolean) => void; - invalidValue: string; -}): (setting: Setting) => () => void { +}): (setting: Setting) => void { return (setting) => { - let timeout: number | undefined; setting .setClass('sync-engine-togglable-value') .addText((text) => { - text.setPlaceholder(placeholder).setValue(format(field.value, type)); - text.inputEl.addEventListener('blur', () => { - const value = parse(text.inputEl.value, type); - if ( - value === undefined || - Number.isNaN(value) || - value < 0 || - (rejectZero && value === 0) - ) { - text.inputEl.value = format(field.value, type); - setting.setErrorMessage(invalidValue); - clearTimeout(timeout); - timeout = window.setTimeout(() => { - setting.setErrorMessage(''); - }, WARNING_INTERVAL); - return; - } - if (value !== field.value) { + text.setPlaceholder(placeholder).setValue(formatType(field.value, type)); + reactivelyValidate({ + format: (value) => formatType(value, type), + onSave: (value) => { field.value = value; onChange?.(value); void saveSettings(); - } - text.inputEl.value = format(field.value, type); + }, + parse: (value) => { + const parsedValue = parseType(value, type); + if ( + parsedValue === undefined || + Number.isNaN(parsedValue) || + parsedValue < 0 || + (rejectZero && parsedValue === 0) + ) + return; + return parsedValue; + }, + text, }); }) .addToggle((toggle) => { @@ -72,11 +126,86 @@ export function renderTogglableValue({ } }); }); - return () => window.clearTimeout(timeout); }; } -function format(value: number, type: InputType): string { +export function generateEditableList({ + memoryDB, + items, + identifier, + saveSettings, + rerenderSettingTab, + defaultValue, + render, + translations: { add, empty, heading }, + extraButtons, +}: { + memoryDB: DatabaseSync; + items: Array; + identifier: string; + saveSettings: () => Promise; + rerenderSettingTab: () => void; + defaultValue: T; + render: ( + setting: Setting, + item: EphemeralEditableItem, + save: () => void, + ) => void | (() => void); + translations: { add: string; empty: string; heading?: string }; + extraButtons?: Array< + ( + button: ExtraButtonComponent, + list: Array>, + save: () => void, + ) => void + >; +}): SettingDefinitionList { + const ephemeralStore = memoryDB.getStore('ephemeralEditableLists'); + const existingList = ephemeralStore.get(identifier); + let list: Array>; + if (existingList) list = existingList; + else { + list = items.map((value) => ({ new: false, valid: true, value })); + ephemeralStore.set(identifier, list); + } + const saveEdit = () => { + const newList = list.filter(({ valid }) => valid).map(({ value }) => value); + if (JSON.stringify(newList) === JSON.stringify(items)) return; + items.length = 0; + items.push(...newList); + void saveSettings(); + }; + return { + addItem: { + action: () => { + list.push({ new: true, valid: false, value: structuredClone(defaultValue) }); + rerenderSettingTab(); + }, + name: add, + }, + emptyState: empty, + extraButtons: extraButtons + ? extraButtons.map((fn) => (button: ExtraButtonComponent) => fn(button, list, saveEdit)) + : undefined, + heading, + items: list.map((item) => ({ + name: '', + render: (setting) => { + setting.settingEl.addClass('sync-engine-editable-list'); + return render(setting, item, saveEdit); + }, + searchable: false, + })), + onDelete: (index) => { + list.splice(index, 1); + saveEdit(); + rerenderSettingTab(); + }, + type: 'list', + }; +} + +function formatType(value: number, type: InputType): string { switch (type) { case 'number': { return value.toString(); @@ -90,7 +219,7 @@ function format(value: number, type: InputType): string { } } -function parse(value: string, type: InputType): number | undefined { +function parseType(value: string, type: InputType): number | undefined { switch (type) { case 'number': { return Number.parseFloat(value); diff --git a/packages/plugin/src/utils/glob-match.ts b/packages/plugin/src/utils/glob-match.ts index 0f7f2a54..1982819c 100644 --- a/packages/plugin/src/utils/glob-match.ts +++ b/packages/plugin/src/utils/glob-match.ts @@ -31,7 +31,6 @@ function escapeRegExpCharacter(character: string): string { function compileSegment(pattern: string, flags: string): SegmentMatcher { let source = ''; - for (let index = 0; index < pattern.length; index++) { const character = pattern[index]; if (character === '*') { @@ -48,31 +47,44 @@ function compileSegment(pattern: string, flags: string): SegmentMatcher { } const end = pattern.indexOf(']', index + 1); - if (end === -1) { - source += String.raw`\[`; - continue; - } - - let characterClass = pattern.slice(index + 1, end); + if (end === -1 || end === index + 1) + throw new Error( + `Invalid glob pattern: unclosed or empty character class at index ${index}`, + ); + const characterClass = pattern.slice(index + 1, end); const negated = characterClass.startsWith('!') || characterClass.startsWith('^'); - if (negated) characterClass = characterClass.slice(1); - characterClass = characterClass.replaceAll('\\', String.raw`\\`); - source += `[${negated ? '^' : ''}${characterClass}]`; + if (negated && characterClass.length === 1) + throw new Error( + `Invalid glob pattern: empty negated character class at index ${index}`, + ); + source += `[${negated ? '^' : ''}${negated ? characterClass.slice(1) : characterClass}]`; index = end; } - return new RegExp(`^${source}$`, flags); } -function compileRule(rule: GlobMatchRule): CompiledRule | undefined { - const expression = rule.expr.trim().replaceAll('\\', '/'); - if (!expression || expression === '/') return undefined; - +export function normalizeGlob(glob: string): string | undefined { + const expression = glob.trim().replaceAll('\\', '/'); + if (!expression || expression === '/') return; const anchored = expression.startsWith('/'); const directoryOnly = expression.endsWith('/'); const body = expression.replaceAll(/^\/+|\/+$/gv, ''); - if (!body) return undefined; + if (!body) return; + const parts = body.split('/').filter(Boolean); + for (const part of parts) + try { + compileSegment(part, ''); + } catch { + return; + } + return `${anchored ? '/' : ''}${parts.join('/')}${directoryOnly ? '/' : ''}`; +} +function compileRule(rule: GlobMatchRule): CompiledRule { + const expression = rule.expr; + const anchored = expression.startsWith('/'); + const directoryOnly = expression.endsWith('/'); + const body = expression.slice(anchored ? 1 : 0, directoryOnly ? -1 : undefined); const parts = body.split('/'); const flags = rule.caseSensitive ? '' : 'i'; const segments = parts.map((part) => @@ -87,15 +99,6 @@ function compileRule(rule: GlobMatchRule): CompiledRule | undefined { }; } -function compileRules(rules: Array): Array { - const compiled: Array = []; - for (const rule of rules) { - const result = compileRule(rule); - if (result) compiled.push(result); - } - return compiled; -} - function matchesSegments( pattern: Array, path: Array, @@ -221,8 +224,8 @@ export function prepareGlobMatch( inclusion: Array = [], exclusion: Array = [], ): (path: string) => GlobMatchResult { - const inclusions = compileRules(inclusion); - const exclusions = compileRules(exclusion); + const inclusions = inclusion.map(compileRule); + const exclusions = exclusion.map(compileRule); return (path) => { const parsed = parsePath(path); diff --git a/packages/plugin/test/fs-vault.test.ts b/packages/plugin/test/fs-vault.test.ts index 0110f767..0756e881 100644 --- a/packages/plugin/test/fs-vault.test.ts +++ b/packages/plugin/test/fs-vault.test.ts @@ -169,7 +169,7 @@ function createVaultStub(options: VaultHarnessOptions): VaultHarness { const app = { vault: { adapter, - config: options.config, + config: { ...options.config }, getAbstractFileByPath: (path: string) => cached.get(path), }, workspace: { layoutReady: true }, diff --git a/packages/plugin/test/glob-match.test.ts b/packages/plugin/test/glob-match.test.ts index e56b981a..349f338d 100644 --- a/packages/plugin/test/glob-match.test.ts +++ b/packages/plugin/test/glob-match.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test'; import type { GlobMatchRule } from '@/types'; import type { GlobMatchResult } from '@/utils/glob-match'; -import { prepareGlobMatch } from '@/utils/glob-match'; +import { normalizeGlob, prepareGlobMatch } from '@/utils/glob-match'; const rule = (expr: string, caseSensitive = false): GlobMatchRule => ({ caseSensitive, @@ -11,6 +11,17 @@ const rule = (expr: string, caseSensitive = false): GlobMatchRule => ({ const results = (paths: Array, matcher: (path: string) => GlobMatchResult) => Object.fromEntries(paths.map((path) => [path, matcher(path)])); +test('normalizes glob separators and preserves boundary semantics', () => { + expect(normalizeGlob(String.raw` \foo//bar/// `)).toBe('/foo/bar/'); + expect(normalizeGlob('///foo/bar')).toBe('/foo/bar'); + expect(normalizeGlob('foo/bar///')).toBe('foo/bar/'); +}); + +test('rejects empty and unparseable globs', () => { + for (const glob of ['', ' ', '/', '///', 'foo/[', 'foo/[]', 'foo/[!]']) + expect(normalizeGlob(glob)).toBeUndefined(); +}); + test('includes files and advances through directories without rules', () => { const match = prepareGlobMatch(); expect(results(['/', 'some/file.txt', 'some/'], match)).toEqual({ diff --git a/packages/s3/src/setting.ts b/packages/s3/src/setting.ts index 3ae6bfcf..206c1d18 100644 --- a/packages/s3/src/setting.ts +++ b/packages/s3/src/setting.ts @@ -132,7 +132,7 @@ export default function s3Setting( new SecretComponent(app, element) .setValue(settings.secretAccessKey) .onChange((value) => { - settings.secretAccessKey = value; + settings.secretAccessKey = value ?? ''; void saveSettings(); }), ); diff --git a/packages/webdav/src/setting.ts b/packages/webdav/src/setting.ts index d821d52b..e8075e32 100644 --- a/packages/webdav/src/setting.ts +++ b/packages/webdav/src/setting.ts @@ -98,7 +98,7 @@ export default function webdavSetting( new SecretComponent(app, element) .setValue(settings.password) .onChange((password) => { - settings.password = password; + settings.password = password ?? ''; void saveSettings(); }), ); diff --git a/tsconfig.json b/tsconfig.json index acce714b..68c22550 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "verbatimModuleSyntax": true, "allowImportingTsExtensions": true, "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": ["bun"] + "types": ["bun", "@obsidian-typings/obsidian-public-latest"] }, "include": ["scripts/**/*.ts"] }