diff --git a/AGENTS.md b/AGENTS.md index a842c319..f216b128 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ This is the monorepo for an extensible Obsidian syncing plugin to sync vault fil - When any function or class needs to use `Context` as argument, prefer structural typing instead of direct `Context`. - 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) +- Lint warnings in scope must be addressed, except conditional ones (TODO with date, upstream issue) - 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/CHANGELOG.md b/CHANGELOG.md index f8230844..b960b356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## Sync Engine v3.2.0 - 2026-09-28 + +### Core + +- Unified sync strategy, inclusion rules, and exclusion rules into an ordered lost of sync strategies, providing granular control of syncing decisions. +- Supported brace expansion syntax in sync strategy Glob rules. +- Replaced remote migration wizard with a confirmation pop-up reminding manual migration since the set of files to upload can no longer be reliably determined on a single device. +- Fixed local operation failure caused by moving a file onto an existing file. +- Improved internal error handling and logging. +- Fixed minor UI issues on mobile. + +### S3 Module + +- Fixed Backblaze multipart upload initiation failure caused by server CORS filtering. + +### Google Drive Module + +- Fixed sync creates multiple same-name files under the same folder when moving a file onto an existing path. + ## Sync Engine v3.1.9 - 2026-09-21 ### Core diff --git a/README.md b/README.md index effbea2f..51df6147 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,9 @@ Sync Engine is a vault syncing plugin to **synchronize vault files in multiple d **Features**: - [Client-side encryption](https://sync.consensia.cc/deep-dive/modules/encryption) -- Bidirectional / mirror remote / mirror local syncing. +- Granular sync strategy configuration (bidirectional / mirror remote / mirror local / don't sync) using Glob rules. - Startup / periodic / save-on-change syncing. -- Conflict resolution strategies ([smart merge](https://sync.consensia.cc/deep-dive/modules/smart-merge) / keep both / latest survive / keep remote / keep local / skip). +- Conflict resolve strategies ([smart merge](https://sync.consensia.cc/deep-dive/modules/smart-merge) / keep both / latest survive / keep remote / keep local / skip). - Rate / memory control options. - Custom headers. @@ -115,7 +115,7 @@ Below is a list of planned features and improvements, the faster this plugin is - [x] v3.0: Rewrite entirely, dynamic module loading, module store, asymmetric storage, and rebrand - [x] v3.1: Migrate settings to Obsidian v1.13 API -- [ ] v3.2: Granular sync strategy selection / exclusion inclusion rule refactor based on ordered glob match rules. +- [x] v3.2: Granular sync strategy selection / exclusion inclusion rule refactor based on ordered glob match rules. Sync Engine has a [wishlist of features](https://github.com/hesprs/sync-engine/issues/214), you can react with **thumbs up** 👍 on feature comments you would like to have. And the features with more votes will have higher priority. diff --git a/README.zh.md b/README.zh.md index e0730184..95510abf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -54,7 +54,7 @@ Sync Engine 是一款用于**多设备间同步 Vault 笔记文件**的插件, **核心特性**: - [客户端端到端加密](https://sync.consensia.cc/deep-dive/modules/encryption) -- 支持双向同步、镜像远端、镜像本地等多种模式。 +- 使用 Glob 规则进行精细化的同步策略配置(双向同步 / 镜像远程 / 镜像本地 / 不同步)。 - 支持启动时同步、定时同步以及变更自动保存时同步。 - 灵活的冲突解决方案([智能合并](https://sync.consensia.cc/deep-dive/modules/smart-merge)、保留两者、保留最新版、覆盖为远端、覆盖为本地、直接跳过)。 - 速率与内存占用调优选项。 @@ -115,7 +115,7 @@ Sync Engine 正好填补了这一空白:让您自由选择存储服务;插 - [x] 3.0 版本:全盘重构、支持动态模块加载、上线模块商店、引入非对称存储,并完成品牌全新升级 - [x] 3.1 版本:设置项全面适配 Obsidian 1.13 版本新 API -- [ ] 3.2 版本:精细化同步策略选择,基于排序 Glob 匹配规则重构文件包含与排除机制 +- [x] 3.2 版本:精细化同步策略选择,基于排序 Glob 匹配规则重构文件包含与排除机制 Sync Engine 设立了[功能需求清单](https://github.com/hesprs/sync-engine/issues/214),欢迎为您期待的特性点赞 👍 投票。得票越高的功能将获得更高的开发优先级。 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5a25cb84..e9af6508 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -71,6 +71,7 @@ const localeConfig = configGenerator((t) => { items: [ { link: `${usage}/settings`, text: t('settings') }, { link: `${usage}/modules`, text: t('modules') }, + { link: `${usage}/remote-migration`, text: t('remoteMigration') }, ], text: t('usage'), }, diff --git a/docs/.vitepress/i18n.ts b/docs/.vitepress/i18n.ts index 59129d84..18573415 100644 --- a/docs/.vitepress/i18n.ts +++ b/docs/.vitepress/i18n.ts @@ -34,6 +34,7 @@ const en = { nativeName: 'English', permissions: 'Permissions', registration: 'Registration', + remoteMigration: 'Remote Migration', request: 'Request', requestMiddleware: 'RequestMiddleware', s3: 'S3', diff --git a/docs/src/pages/en/deep-dive/architecture.md b/docs/src/pages/en/deep-dive/architecture.md index bf4f7f02..34a9da79 100644 --- a/docs/src/pages/en/deep-dive/architecture.md +++ b/docs/src/pages/en/deep-dive/architecture.md @@ -58,14 +58,14 @@ SynthKernel also supplies reactive primitives used across module boundaries. `Re 3. **`Storage`** owns the Uni-KV memory database and IndexedDB database. It exposes persistent record stores, module metadata storage, and per-local/remote-pair namespaces. 4. **`Extensibility`** discovers module metadata, validates sources and integrity, loads enabled JavaScript modules, persists metadata, and manages enable, disable, update, and unload operations. Its security and trust rules are documented in the [Extensibility Contract](./extensibility). 5. **`Setting`** owns the native Obsidian settings tab, nested setting-definition tree, module setting registration, labels, and settings-page refreshes. -6. **`Registrar`** is the capability and registry layer. It creates local and remote file systems, applies request middleware and wrappers, selects optimizers, deciders, and conflict resolvers, reduces trigger entries, and exposes registration functions to modules. +6. **`Registrar`** is the capability and registry layer. It creates local and remote file systems, applies request middleware and wrappers, selects optimizers and conflict resolvers, buckets stats by strategy and dispatches them to deciders, reduces trigger entries, and exposes registration functions to modules. 7. **`Sync`** executes one sync run: initialize infrastructure, traverse both sides, filter stats, create and transform tasks, request confirmations, execute tasks, and publish lifecycle events. See [Core Sync Routine](./sync). 8. **`Observability`** converts events into user-visible status, progress, notices, commands, ribbon controls, and exported logs. Its reactive values are consumed by the progress modal. 9. **`Scheduler`** turns manual, startup, scheduled, realtime, and vault-change triggers into queued sync requests. It waits for idle state, batches pending requests, reduces the batch's triggers to the highest-priority registered entry, and resolves every request in a batch with the same result. 10. **`ProgressModal`** handles progress display, task confirmation, deletion confirmation, cancellation, and failed-task details. SynthKernel `computed()` values and `hook()` cleanup keep modal state scoped to the modal lifecycle. 11. **`Bootstrap`** installs built-in sync capabilities through `Registrar` and supplies core translations. Settings are registered by `Setting`, which starts before `Bootstrap` completes the plugin lifecycle. -The dependency direction is intentionally visible in the constructors. For example, `Sync` receives `initializeSync`, `getDecider`, and `getConflictResolver`; it does not know which backend, wrapper, or middleware supplied them. `Bootstrap` assembles those policies without changing the sync algorithm. +The dependency direction is intentionally visible in the constructors. For example, `Sync` receives `initializeSync`, `decideTasks`, and `getConflictResolver`; it does not know which backend, wrapper, or middleware supplied them. `Bootstrap` assembles those policies without changing the sync algorithm. ## Extensibility Framework @@ -86,7 +86,7 @@ Examples include `registerRemoteFs`, `registerRemoteFsWrapper`, `registerRemoteR Registration entries are consumed according to their role: -- **Factories and ID maps:** the selected remote backend, decider, and conflict resolver are looked up by the ID in settings. Missing IDs fail with an explicit error. +- **Factories and ID maps:** the selected remote backend and conflict resolver are looked up by the ID in settings, while deciders are looked up by strategy name during planning. Missing IDs fail with an explicit error. - **Wrappers:** request and file-system wrappers are grouped by numeric priority and applied in ascending priority order. Within one priority, the first wrapper that returns a replacement wins; returning `undefined` declines the current value. - **First-match pipelines:** optimizers are also priority ordered. The first entry that returns a result supplies the implementation for that operation. - **Triggers:** trigger entries are keyed by trigger name. A flushed sync batch is reduced to its highest-priority entry, whose options customize the run. diff --git a/docs/src/pages/en/deep-dive/modules/encryption.md b/docs/src/pages/en/deep-dive/modules/encryption.md index c7762591..f5a6a546 100644 --- a/docs/src/pages/en/deep-dive/modules/encryption.md +++ b/docs/src/pages/en/deep-dive/modules/encryption.md @@ -77,7 +77,7 @@ Encryption implementation in this module welcomes volunteer auditing. ## Enabling and Disabling -The function is toggled via the **Encryption** setting. The password is stored in Obsidian's keychain. When a record store exists, changing the toggle opens a migration confirmation, which is similar to [the migration of asymmetric storage](../../usage/settings#asymmetric-storage). +The function is toggled via the **Encryption** setting. The password is stored in Obsidian's keychain. When a record store exists, changing the toggle opens a confirmation prompt, similar to [asymmetric storage](../../usage/settings#asymmetric-storage); confirm only after your remote data has been migrated as described in the [Remote Migration guide](../../usage/remote-migration). ## Sync Routine diff --git a/docs/src/pages/en/deep-dive/modules/s3.md b/docs/src/pages/en/deep-dive/modules/s3.md index c8f9b5c9..619df242 100644 --- a/docs/src/pages/en/deep-dive/modules/s3.md +++ b/docs/src/pages/en/deep-dive/modules/s3.md @@ -131,4 +131,4 @@ The final part may be smaller than 5 MiB. On failure, active part uploads finish ### Batch Deletes -The S3 optimizer groups delete atoms into `DeleteObjects` requests with at most 1000 keys each. Requests contain XML and a `Content-MD5` header. The service result is handled per key: successful keys resolve, while keys reported in an S3 error result reject individually. If the whole batch request fails, each key is retried with an individual `DELETE` request and handled independently. +The S3 optimizer replaces delete atoms with a single custom atom, whose `batchDelete()` splits the keys into `DeleteObjects` requests with at most 1000 keys each. Requests contain XML and a `Content-MD5` header. The service result is handled per key: successful keys resolve, while keys reported in an S3 error result reject individually. If the whole batch request fails, each key is retried with an individual `DELETE` request and handled independently. diff --git a/docs/src/pages/en/deep-dive/sync.md b/docs/src/pages/en/deep-dive/sync.md index 3ff3d6d7..3f38b1b6 100644 --- a/docs/src/pages/en/deep-dive/sync.md +++ b/docs/src/pages/en/deep-dive/sync.md @@ -1,16 +1,16 @@ # Core Sync Routine -The core sync routine is implemented by `Sync` in `packages/plugin/src/modules/Sync.ts`. It compares filtered local and remote stats with the persistent record store, creates a task plan, and executes the plan against the [file system abstraction](./file-system). +The core sync routine is implemented by `Sync` in `packages/plugin/src/modules/Sync.ts`. It buckets local and remote stats by sync strategy, compares each bucket with the persistent record store, creates a task plan, and executes the plan against the [file system abstraction](./file-system). ## Sync Trigger -The scheduler accepts requests from manual controls, [realtime sync](../usage/settings#realtime-sync), [startup sync](../usage/settings#startup-sync), [scheduled sync](../usage/settings#scheduled-sync), and migration. Trigger names are `manual`, `nonInteractiveManual`, `realtime`, `startup`, `interval`, and `migration`. +The scheduler accepts requests from manual controls, [realtime sync](../usage/settings#realtime-sync), [startup sync](../usage/settings#startup-sync), and [scheduled sync](../usage/settings#scheduled-sync). Trigger names are `manual`, `nonInteractiveManual`, `realtime`, `startup`, and `interval`. Each trigger name can carry one registered entry with a priority and an options factory. Pending requests wait until the plugin is idle, then flush as one sync: the scheduler reduces the batch's trigger names to the entry with the highest priority, and that entry's `options()` customizes the run. A batch without registered entries falls back to the `unknown` trigger with default options. Every request in the batch receives the same result. -The built-in entries are registered by Bootstrap: `realtime` (priority 1000) swaps in the fast remote lister, which reuses cached `remoteContext20000` stats when [realtime sync fast mode](../usage/settings#realtime-sync-fast-mode) is enabled and the cache has entries, skipping a fresh remote walk; `interval` (2000), `startup` (3000), and `realtime` request local-delete confirmation when [Confirm Deletions During Auto-Sync](../usage/settings#confirm-deletions-during-auto-sync) is enabled; `migration` (3980) uses the mirror-local decider, disables move detection, and lists the remote as empty because migration phase 2 has already cleared it; `nonInteractiveManual` (3990) runs with defaults; `manual` (4000) requests task confirmation when [Confirm Operations in Manual Sync](../usage/settings#confirm-operations-in-manual-sync) is enabled. The non-interactive manual command uses a separate trigger so it can skip manual task confirmation. +The built-in entries are registered by Bootstrap: `realtime` (priority 1000) swaps in the fast remote lister, which reuses cached `remoteContext20000` stats when [realtime sync fast mode](../usage/settings#realtime-sync-fast-mode) is enabled and the cache has entries, skipping a fresh remote walk; `interval` (2000), `startup` (3000), and `realtime` request local-delete confirmation when [Confirm Deletions During Auto-Sync](../usage/settings#confirm-deletions-during-auto-sync) is enabled; `nonInteractiveManual` (3990) runs with defaults; `manual` (4000) requests task confirmation when [Confirm Operations in Manual Sync](../usage/settings#confirm-operations-in-manual-sync) is enabled. The non-interactive manual command uses a separate trigger so it can skip manual task confirmation. -Realtime events are debounced and filtered before they schedule a request. Changes made while a sync is executing are ignored. A rename schedules a request when either its old or new path is in scope; see [inclusion and exclusion rules](../usage/settings#inclusion-and-exclusion-rules) for the rule configuration. +Realtime events are debounced and filtered before they schedule a request. Changes made while a sync is executing are ignored. A rename schedules a request when either its old or new path is in scope; see [Sync Strategy Rules](../usage/settings#sync-strategy-rules) for the rule configuration. ## Cancellation @@ -18,21 +18,23 @@ Each run creates a cancellation reference. Stop controls, the progress modal, co Cancellation does not roll back completed operations. Task errors raised after cancellation are ignored, but execution waits for task promises to settle before reporting `cancelled`. -## Traversal and Glob Matching +## Traversal and Strategy Matching After infrastructure initialization, the routine dispatches `syncInitialized` with the run's `Infras` and the compiled matcher. -The routine compiles the configured matcher once, then starts local and remote discovery concurrently. Local traversal calls `localFs.list('/')` with the matcher. Full remote traversal receives a reporter that forwards progress and applies the matcher to each reported path. +The routine compiles the configured strategy rules once, then starts local and remote discovery concurrently. Local traversal calls `localFs.list('/')` with the matcher. Full remote traversal receives a reporter that forwards progress and applies the matcher to each reported path. The default remote lister performs the full traversal. If the remote root does not exist, it recreates the root, clears records for the local/remote pair, and returns an empty list. -The matcher returns `include`, `exclude`, or `advance`. Files are included or excluded; `advance` continues through a directory without including the directory itself. An excluded directory is advanced only when an inclusion rule could match a descendant. Rule syntax and matching precedence are documented in [Inclusion and Exclusion Rules](../usage/settings#inclusion-and-exclusion-rules). +The matcher resolves each path to a strategy name plus an `advance` flag. For files, the strategy alone decides membership. For folders, `advance` controls traversal: a synchronized folder is traversed and its descendants inherit its strategy unless a later catch-all `none` rule shadows it; a folder that is itself not synchronized but could contain content matched by a later rule is traversed as a probe. The reporter maps these results to `include`, `exclude`, or `advance` for the backend's `list()`. Rule syntax and precedence are documented in [Sync Strategy Rules](../usage/settings#sync-strategy-rules). -After discovery, `postTraversal` removes entries over the configured [maximum file size](../usage/settings#max-file-size) and converts the lists into stats maps. +After discovery, entries over the configured [maximum file size](../usage/settings#max-file-size) are removed, probed content is pruned, and the remaining stats are organized into per-strategy buckets: `Record` for local and remote stats, and `Record` for records. ## Decider -The selected decider receives filtered local stats, filtered remote stats, persistent records, a task factory, and a logger. Built-in deciders union all keys found in either side or in the records. The selected [sync strategy](../usage/settings#sync-strategy) can be supplied by a module. +Planning is strategy-driven. `decideTasks` unions the keys of the local, remote, and record buckets, looks up the registered decider for each strategy name, and fails with a `syncStrategyNotInstalled` error when a strategy has no decider. Each decider receives only its own bucket — `localStats`, `remoteStats`, `records`, and a `taskFactory` — and its tasks are concatenated into the run's task plan. `none` is not a decider; entries resolved to `none` are already dropped before planning. + +The built-in strategies are registered by Bootstrap and are selectable in [Sync Strategy](../usage/settings#sync-strategy) rules: **Bidirectional sync strategy**: @@ -46,7 +48,7 @@ These two deciders make one side authoritative. They copy authoritative files, c ## Move Detection -Move detection runs after the decider unless the run disables it; the migration trigger disables it. It pairs a delete task with a create task on the same side when their recorded/current file UIDs match, then replaces the pair with `moveLocal` or `moveRemote`. +Move detection runs after the decider unless the run disables it. It pairs a delete task with a create task on the same side when their recorded/current file UIDs match, then replaces the pair with `moveLocal` or `moveRemote`. It repeatedly looks for folder delete/create pairs. A folder pair is converted only when every relevant child has a compatible move into one destination and keeps its basename. New files without recorded identity, incomplete child plans, and ambiguous destinations remain ordinary delete/create operations. diff --git a/docs/src/pages/en/development/events.md b/docs/src/pages/en/development/events.md index 8eb36214..d1fd068a 100644 --- a/docs/src/pages/en/development/events.md +++ b/docs/src/pages/en/development/events.md @@ -43,8 +43,8 @@ unsubscribe(); | ---------------------- | ------------------------------------------------------------------------------------------------------------------- | | `logSync` | `string` sync log message | | `logGeneral` | `string` general log message | -| `errorSync` | `string` sync error log message | -| `errorGeneral` | `string` general error log message | +| `errorSync` | `Error` sync error | +| `errorGeneral` | `Error` general error | | `moduleLoaded` | `string` module name | | `moduleUnloaded` | `string` module name | | `syncStarted` | `{ isCancelled: Ref; trigger: string }` | @@ -55,7 +55,7 @@ unsubscribe(); | `requestConfirmTasks` | `Array` | | `syncCanceled` | `undefined` (no payload) | | `taskCompleted` | `TaskInfo` (`{ name: TaskNames; key: string; prettyName: string; isDir: boolean }`) | -| `taskFailed` | `FailedTaskInfo` (`TaskInfo` & `{ error: string }`) | +| `taskFailed` | `FailedTaskInfo` (`TaskInfo` & `{ error: Error }`) | | `executionStarted` | `Array` | | `tasksConfirmed` | `Array` | | `deleteConfirmed` | `{ delete: Array; reupload: Array }` | @@ -68,4 +68,4 @@ unsubscribe(); ## Sync Lifecycle Events -`syncStarted` fires before the file-system stacks exist; `syncInitialized` fires once per run after infrastructure initialization and before traversal, and is the only point where the sync's actual `localFs`, `remoteFs`, and `record` are published. Its `Infras` shape is `{ localFs: Fs; remoteFs: Fs; record: RecordStore }`, documented with the [remote lister](./sync#remote-lister); `match` is the compiled [inclusion/exclusion matcher](../usage/settings#inclusion-and-exclusion-rules). +`syncStarted` fires before the file-system stacks exist; `syncInitialized` fires once per run after infrastructure initialization and before traversal, and is the only point where the sync's actual `localFs`, `remoteFs`, and `record` are published. Its `Infras` shape is `{ localFs: Fs; remoteFs: Fs; record: RecordStore }`, documented with the [remote lister](./sync#remote-lister); `match` is the compiled [sync strategy matcher](../usage/settings#sync-strategy-rules). diff --git a/docs/src/pages/en/development/file-system.md b/docs/src/pages/en/development/file-system.md index 97f1a01a..d84a745e 100644 --- a/docs/src/pages/en/development/file-system.md +++ b/docs/src/pages/en/development/file-system.md @@ -55,10 +55,10 @@ type RootFs = { ### `ListReporter` -The `list` method receives a reporter callback the backend must invoke during traversal. It receives a `Required` (all fields present) and returns a `GlobMatchResult` controlling glob inclusion. +The `list` method receives a reporter callback the backend must invoke during traversal. It receives a `Required` (all fields present) and returns a `ListOptions` controlling how the path is handled. ```ts -type ListReporter = (progress: Required) => MaybePromise; +type ListReporter = (progress: Required) => MaybePromise; ``` ### Method Notes @@ -70,10 +70,10 @@ type ListReporter = (progress: Required) => MaybePromise; + if (!(original instanceof S3Fs)) return; const deleteAtoms = atoms.filter((a): a is DeleteAtom => a.type === 'delete'); + if (deleteAtoms.length <= 1) return atoms; const otherAtoms = atoms.filter((a) => a.type !== 'delete'); - if (deleteAtoms.length === 0) return atoms; - const batchGroups: Array> = []; - for (let i = 0; i < deleteAtoms.length; i += BATCH_DELETE_MAX_KEYS) - batchGroups.push(deleteAtoms.slice(i, i + BATCH_DELETE_MAX_KEYS)); - const batchAtoms = batchGroups.map((batch) => ({ + const batchAtom = { execute: async () => { - const keys = batch.map((a) => a.key); + const keys = deleteAtoms.map((a) => a.key); try { - const result = await s3Fs.batchDelete(keys); - batch.forEach((atom) => { + const result = await original.batchDelete(keys); + deleteAtoms.forEach((atom) => { const status = result[atom.key]; - if (status === true) atom.resolve(); - else atom.reject(new Error(status ?? `S3 batch delete missing result for ${atom.key}.`)); + if (status) atom.reject(status); + else atom.resolve(); }); } catch (error) { const reason = error instanceof Error ? error : new Error(String(error)); - batch.forEach((atom) => atom.reject(reason)); + deleteAtoms.forEach((atom) => atom.reject(reason)); } }, type: 'custom' as const, - })); - return [...otherAtoms, ...batchAtoms]; + }; + return [...otherAtoms, batchAtom]; } ``` +`S3Fs.batchDelete()` itself splits the keys into `DeleteObjects` requests of at most 1000 keys and resolves to a record containing only the failed keys, each mapped to its `Error`. + More complex example: [Hierarchical Optimizer](https://github.com/hesprs/sync-engine/tree/main/packages/plugin/src/fs/hierarchical-optimizer.ts). ### Registering an Optimizer diff --git a/docs/src/pages/en/development/miscellaneous.md b/docs/src/pages/en/development/miscellaneous.md index 03c62fd2..5249be4b 100644 --- a/docs/src/pages/en/development/miscellaneous.md +++ b/docs/src/pages/en/development/miscellaneous.md @@ -37,8 +37,8 @@ | Context | `Context`, `Settings`, `Events`, `Translations`, `SelectFromContext` | | Events | `Dispatch`, `On` | | Core data | `Binary`, `MaybePromise`, `Progress`, `FileStat`, `FolderStat`, `Stat`, `StatsMap`, `RecordStat`, `RecordStatsMap` | -| Filesystem | `RootFs`, `WrappedFs`, `Fs`, `WriteAtom`, `DeleteAtom`, `MoveAtom`, `MkdirAtom`, `InputAtom`, `CustomAtom`, `OutputAtom`, `OptimizerInput`, `OptimizerOutput`, `BatchOptimizer` | -| Registration | `FsWrapperEntry`, `RemoteFsEntry`, `RemoteRequestMiddlewareEntry`, `LocalRequestMiddlewareEntry`, `TriggerEntry`, `DeciderEntry`, `OptimizerEntry`, `SettingEntry`, `CallableOrObjectTree`, `LabelDefinition`, `ConflictResolverEntry`, `Request`, `CheckConnectionResult` | +| Filesystem | `RootFs`, `WrappedFs`, `Fs`, `WriteAtom`, `DeleteAtom`, `MoveAtom`, `MkdirAtom`, `InputAtom`, `CustomAtom`, `OutputAtom`, `ListOptions`, `ListReporter`, `OptimizerInput`, `OptimizerOutput`, `BatchOptimizer` | +| Registration | `FsWrapperEntry`, `RemoteFsEntry`, `RemoteRequestMiddlewareEntry`, `LocalRequestMiddlewareEntry`, `TriggerEntry`, `DeciderEntry`, `OptimizerEntry`, `SettingEntry`, `CallableOrObjectTree`, `LabelDefinition`, `ConflictResolverEntry`, `Request` | | Sync | `TaskNames`, `BaseTask`, `AddRecord`, `RemoveRecord`, `Download`, `Upload`, `CreateLocalDir`, `CreateRemoteDir`, `RemoveLocal`, `RemoveRemote`, `MoveLocal`, `MoveRemote`, `ResolveConflict`, `TaskFactory`, `DeciderInput`, `Decider`, `SyncOptions`, `RemoteLister`, `ConflictResolver`, `ConflictResolverPayload`, `SyncTerminateReason` | | Storage | `RecordStore`, `StoreAsync`, `StoreSync`, `StoreOperations`, `DatabaseAsync`, `DatabaseSync` | | Modules | `ModuleMeta`, `AugmentedModuleMeta` | @@ -46,7 +46,7 @@ | Internationalization | `ObsidianLanguageCode`, `Fragment`, `Snippet`, `TranslationResource`, `Translate` | | Other | `ExistingMemoryDB` | -Internal supporting types can appear in exported signatures but are not standalone root exports. They include `TogglableValue`, `GlobMatchRule`, `Infras`, `BaseTaskOptions`, `TaskOptions`, `TaskOptionsMap`, `TaskInfo`, `FailedTaskInfo`, `DeleteConfirmReturn`, and `CustomHeaders`. +Internal supporting types can appear in exported signatures but are not standalone root exports. They include `TogglableValue`, `GlobStrategy`, `GlobMatchResult`, `Infras`, `BaseTaskOptions`, `TaskOptions`, `TaskOptionsMap`, `TaskInfo`, `FailedTaskInfo`, `DeleteConfirmReturn`, and `CustomHeaders`. ## Settings @@ -58,7 +58,7 @@ Internal supporting types can appear in exported signatures but are not standalo | `modules` | `Record` of per-module settings | | `moduleAutoUpdate` | `boolean` | | `remoteFs` | `string` selected backend ID | -| `decider` | `string` selected decider ID | +| `syncStrategy` | `Array` ordered strategy rules | | `conflictResolver` | `string` selected conflict-resolver ID | | `maxFileSize` | `TogglableValue` max file size in bytes | | `confirmTasksInSync` | `boolean` | @@ -68,8 +68,6 @@ Internal supporting types can appear in exported signatures but are not standalo | `startupSync` | `TogglableValue` delay in milliseconds | | `scheduledSync` | `TogglableValue` interval in milliseconds | | `realtimeSync` | `TogglableValue` debounce delay in milliseconds | -| `inclusionRules` | `Array` | -| `exclusionRules` | `Array` | | `maxMemoryConsumption` | `TogglableValue` in bytes | | `maxRequestConcurrency` | `TogglableValue` | | `minRequestInterval` | `TogglableValue` in milliseconds | @@ -104,25 +102,25 @@ These Context members are not commonly used by modules. Explore source code to o ### Filesystem and Sync -| Member | Purpose | -| -------------------------------- | ------------------------------------------------------------ | -| `createLocalFs()` | Creates wrapped local filesystem. | -| `createRemoteFs(id?)` | Creates selected wrapped remote filesystem. | -| `getRequest()` | Gets composed remote request function. | -| `getVaultRequest()` | Gets composed local vault request function. | -| `getNamespace(local?, remote?)` | Creates storage namespace for optional local/remote FS pair. | -| `initializeSync()` | Creates local FS, remote FS, and record store for sync. | -| `getCheckConnection()` | Gets selected backend connection-check function. | -| `getDecider()` | Gets selected `Decider`. | -| `getConflictResolver()` | Gets selected `ConflictResolver`. | -| `optimizeLocal(input)` | Applies selected local `BatchOptimizer`. | -| `optimizeRemote(input)` | Applies selected remote `BatchOptimizer`. | -| `reduceTriggers(triggers)` | Reduces trigger names to the highest-priority entry. | -| `executeSync(trigger, options?)` | Executes synchronization immediately. | -| `requestSync(trigger)` | Queues sync; resolves with `SyncTerminateReason`. | -| `remoteFsRegistry` | `Map`. | -| `deciderRegistry` | `Map`. | -| `conflictResolverRegistry` | `Map`. | +| Member | Purpose | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `createLocalFs()` | Creates wrapped local filesystem. | +| `createRemoteFs(id?)` | Creates selected wrapped remote filesystem. Returns an `Error` when the backend is missing. | +| `getRequest()` | Gets composed remote request function. | +| `getVaultRequest()` | Gets composed local vault request function. | +| `getNamespace(local?, remote?)` | Creates storage namespace for optional local/remote FS pair. Returns an `Error` when no backend is set. | +| `initializeSync()` | Creates local FS, remote FS, and record store for sync. | +| `getCheckConnection()` | Gets selected backend connection-check function. | +| `decideTasks(input)` | Buckets stats by strategy, invokes the registered decider per bucket, and returns the task plan. | +| `getConflictResolver()` | Gets selected `ConflictResolver`. | +| `optimizeLocal(input)` | Applies selected local `BatchOptimizer`. | +| `optimizeRemote(input)` | Applies selected remote `BatchOptimizer`. | +| `reduceTriggers(triggers)` | Reduces trigger names to the highest-priority entry. | +| `executeSync(trigger, options?)` | Executes synchronization immediately. | +| `requestSync(trigger)` | Queues sync; resolves with `SyncTerminateReason`. | +| `remoteFsRegistry` | `Map`. | +| `deciderRegistry` | `Map`. | +| `conflictResolverRegistry` | `Map`. | ### UI and Observability diff --git a/docs/src/pages/en/development/registration.md b/docs/src/pages/en/development/registration.md index e9964c09..4250faee 100644 --- a/docs/src/pages/en/development/registration.md +++ b/docs/src/pages/en/development/registration.md @@ -38,15 +38,15 @@ Returning `undefined` from `apply` declines the entry. Register a remote backend implementation. See [file system: RootFs](./file-system#rootfs) for the contract. ```ts -type CheckConnectionResult = { success: true } | { success: false; reason: string }; - type RemoteFsEntry = { prettyName: () => string; instantiate: (request: Request) => RootFs; - checkConnection: (request: Request) => MaybePromise; + checkConnection: (request: Request) => MaybePromise; }; ``` +`checkConnection` resolves `undefined` on success, or an `Error` carrying the failure reason. + ```ts ctx.registerRemoteFs(id: string, entry: RemoteFsEntry): () => boolean; ``` @@ -104,7 +104,7 @@ ctx.registerTrigger(key: string, entry: TriggerEntry): () => boolean; ## Decider -Register a sync decision strategy. See [sync: decider](./sync#decider). +Register a sync decision strategy. Stats are bucketed by the registered ID during planning, so each ID doubles as a selectable strategy in [Sync Strategy](../usage/settings#sync-strategy) rules. See [sync: decider](./sync#decider). ```ts type DeciderEntry = { decider: Decider; prettyName: () => string }; diff --git a/docs/src/pages/en/development/settings-and-ui.md b/docs/src/pages/en/development/settings-and-ui.md index 47596ec2..bbba0f2e 100644 --- a/docs/src/pages/en/development/settings-and-ui.md +++ b/docs/src/pages/en/development/settings-and-ui.md @@ -135,14 +135,13 @@ Use a `group` for a heading and its children, a `page` for a separate settings p Core setting sections use these priorities: -| Section | Priority | Contents | -| ----------------- | -------: | -------------------------------------------------------------------------- | -| Top configuration | `0` | Backend, module management, decider, conflict resolver. | -| Features | `1000` | Realtime, startup, scheduled sync; realtime fast mode; asymmetric storage. | -| Controls | `2000` | File-size, request-concurrency, request-interval, memory limits. | -| Filter rules | `3000` | Inclusion and exclusion rule pages. | -| Miscellaneous | `4000` | Custom headers, mobile notices, task confirmation, deletion confirmation. | -| Development | `5000` | Record cleanup, log export, and module source pages. | +| Section | Priority | Contents | +| ----------------- | -------: | ---------------------------------------------------------------------------------- | +| Top configuration | `0` | Backend, module management, auto-update, sync strategy, conflict resolver. | +| Features | `1000` | Realtime, startup, scheduled sync; realtime fast mode; asymmetric storage. | +| Controls | `2000` | File-size, request-concurrency, request-interval, memory limits. | +| Miscellaneous | `3000` | Custom headers, mobile notices, offline behavior, task and deletion confirmations. | +| Development | `4000` | Record cleanup, log export, and module source pages. | Settings contributed by modules are included when the module loads and removed when it unloads. The settings tab refreshes after either event. @@ -294,7 +293,7 @@ Register translations before settings or other UI that uses them. `registerI18n( ## Migration-Aware Toggles -Use `setNeedMigration()` when changing a toggle can make existing records or remote files incompatible with the new setting. The helper opens Sync Engine's migration dialog when migration is required. +Use `setNeedMigration()` when changing a toggle can make existing records or remote files incompatible with the new setting. The helper opens a confirmation dialog when the change requires a manual migration. ```ts import { setNeedMigration } from '@hesprs/sync-engine-sdk'; @@ -310,8 +309,8 @@ setNeedMigration(ctx, { }); ``` -`needMigration` receives the proposed boolean value and may return a boolean or promise. When it returns `true`, the toggle is reverted and the user can cancel, apply the change without migration, or start migration. When it returns `false`, `apply` runs immediately. If `needMigration` is omitted, migration is required by default. +`needMigration` receives the proposed boolean value and may return a boolean or promise. When it returns `true`, the toggle is reverted and a confirmation dialog opens; confirming runs `apply` to change the setting, and canceling keeps the old value. Sync Engine does not perform the migration itself — the dialog points to the documentation for how to migrate manually. When `needMigration` returns `false`, `apply` runs immediately. If `needMigration` is omitted, confirmation is required by default. -`content` supplies the explanation shown in the migration dialog. `apply` runs after the user chooses either migration or toggle-without-migration. Both callbacks may be asynchronous. +`content` supplies the explanation shown at the top of the dialog. Both callbacks may be asynchronous. -The migration dialog is an internal UI component; `setNeedMigration()` is the public SDK helper. +The dialog is an internal UI component; `setNeedMigration()` is the public SDK helper. diff --git a/docs/src/pages/en/development/storage.md b/docs/src/pages/en/development/storage.md index 1f94fe0c..aae61c57 100644 --- a/docs/src/pages/en/development/storage.md +++ b/docs/src/pages/en/development/storage.md @@ -34,7 +34,7 @@ Stores expose `get`, `set`, `delete`, `clear`, `keys`, `values`, `entries`, and type RecordStore = StoreAsync; ``` -Persists synchronization record entries. `getRecordStore()` selects the current local/remote filesystem namespace. +Persists synchronization record entries. `getRecordStore()` selects the current local/remote filesystem namespace. When no backend is set, it returns an `Error`. ```ts const records = ctx.getRecordStore(); diff --git a/docs/src/pages/en/development/sync.md b/docs/src/pages/en/development/sync.md index 4d3f32f5..07be56b1 100644 --- a/docs/src/pages/en/development/sync.md +++ b/docs/src/pages/en/development/sync.md @@ -17,22 +17,22 @@ type TriggerEntry = { ctx.registerTrigger(key: string, entry: TriggerEntry): () => boolean; ``` -`SyncOptions` customizes one run. Unset fields fall back to defaults: the selected decider and conflict resolver, the configured inclusion and exclusion rules, move detection on, and no confirmations. +`SyncOptions` customizes one run. Unset fields fall back to defaults: the selected conflict resolver, the configured strategy rules, move detection on, and no confirmations. ```ts type SyncOptions = { - decider?: Decider; remoteLister?: RemoteLister; conflictResolver?: ConflictResolver; detectMoves?: boolean; needConfirmTasks?: boolean; needConfirmDeletion?: boolean; - inclusionRules?: Array; - exclusionRules?: Array; + syncStrategy?: Array; }; ``` -The plugin registers built-in entries: `realtime` (priority 1000), `interval` (2000), `startup` (3000), `migration` (3980), `nonInteractiveManual` (3990), and `manual` (4000). Registering under an existing name replaces the entry. +`GlobStrategy` is `{ expr: string; strategy: string }`, where `strategy` is a registered decider ID or `none`. Supplying `syncStrategy` replaces the configured rules for that run; it does not merge with them. + +The plugin registers built-in entries: `realtime` (priority 1000), `interval` (2000), `startup` (3000), `nonInteractiveManual` (3990), and `manual` (4000). Registering under an existing name replaces the entry. ### Remote Lister @@ -50,7 +50,7 @@ See [registration](./registration#sync-trigger). ## Decider -A `Decider` compares local stats, remote stats, and prior records to produce sync tasks. +A `Decider` compares local stats, remote stats, and prior records to produce sync tasks for one strategy. ```ts type DeciderInput = { @@ -58,13 +58,12 @@ type DeciderInput = { remoteStats: StatsMap; records: RecordStatsMap; taskFactory: TaskFactory; - logger: (log: string) => void; }; type Decider = (input: DeciderInput) => Array; ``` -Use `taskFactory` instead of constructing task classes directly — their constructors require internal sync infrastructure. Built-in deciders include bidirectional, mirror-local, and mirror-remote; see [deep dive: sync](../deep-dive/sync#decider) for their behavior. +Stats are bucketed by strategy name before planning; `decideTasks` invokes the registered decider once per bucket that has entries. Each registered decider ID becomes a selectable strategy in [Sync Strategy](../usage/settings#sync-strategy) rules. Use `taskFactory` instead of constructing task classes directly — their constructors require internal sync infrastructure. Built-in deciders include `bidirectional`, `mirrorLocal`, and `mirrorRemote`; see [deep dive: sync](../deep-dive/sync#decider) for their behavior. Examples: [bidirectional decider](https://github.com/hesprs/sync-engine/blob/main/packages/plugin/src/sync/decision/bidirectional.ts) and [mirror deciders](https://github.com/hesprs/sync-engine/blob/main/packages/plugin/src/sync/decision/mirror.ts). @@ -191,6 +190,6 @@ This is the return type of `ctx.requestSync`, `ctx.executeSync`, and the payload type SyncTerminateReason = | { result: 'cancelled' } | { result: 'completed' } - | { result: 'failed'; error: string } + | { result: 'failed'; error: Error } | { result: 'noop' }; ``` diff --git a/docs/src/pages/en/usage/migration.md b/docs/src/pages/en/usage/migration.md index efd8b22f..2be07e99 100644 --- a/docs/src/pages/en/usage/migration.md +++ b/docs/src/pages/en/usage/migration.md @@ -27,7 +27,7 @@ If any of the 1-4 step fails, the migration will be rolled back immediately, no Due to the revamped encryption schema, **WebDAV Sync encrypted files will be no longer accessible by Sync Engine's Encryption module**. So if you are using Encryption, you are required to delete remote base directory and **re-sync your vault in Sync Engine**. If you are not using encryption, you simply need to download Sync Engine from Obsidian module store and disable WebDAV Sync, then everything is done. -For seamless migration, **Sync Engine's most ingenious feature _Anchored Asymmetric Storage_ is disabled by default** if you are not using encryption (when encryption is enabled, this is left enabled since you will need to re-sync the entire vault anyway). You can enable that and use Sync Engine's built-in migration feature to transform your vault, then your every sync will be accelerated by this technology. +For seamless migration, **Sync Engine's most ingenious feature _Anchored Asymmetric Storage_ is disabled by default** if you are not using encryption (when encryption is enabled, this is left enabled since you will need to re-sync the entire vault anyway). You can enable that and follow the [Remote Migration guide](./remote-migration) to transform your vault manually, then your every sync will be accelerated by this technology. ### Version `2.5.14` diff --git a/docs/src/pages/en/usage/permissions.md b/docs/src/pages/en/usage/permissions.md index 564244fb..87791d4e 100644 --- a/docs/src/pages/en/usage/permissions.md +++ b/docs/src/pages/en/usage/permissions.md @@ -16,7 +16,7 @@ Sync Engine accesses and modifies vault files for basic syncing and module manag It obtains the last modified time and file size for each file to detect changes; reads files and uploads to configured syncing backend; and updates or deletes local files according to detected remote changes. Deleted files follow Obsidian's trash setting and can go to system trash, the vault trash folder, or be permanently deleted. -Sync Engine only accesses and modifies vault files during sync runs, and it never tries to access files that are explicitly excluded by [inclusion and exclusion rules](./settings#inclusion-and-exclusion-rules). +Sync Engine only accesses and modifies vault files during sync runs, and it never tries to access files that are explicitly excluded by [Don't-sync strategy rules](./settings#sync-strategy-rules). Sync Engine also persists its modules in `/.obsidian/plugins/sync-engine/modules/`. @@ -42,6 +42,7 @@ Obsidian secret storage is shown as "Keychain" in Obsidian settings. Sync Engine - Plugin core: reads the configured [secret headers](./settings#custom-headers) at the start of a sync run - [WebDAV](../deep-dive/modules/webdav) module: reads the configured WebDAV token secret at the start of a sync run for service authentication - [S3](../deep-dive/modules/s3) module: reads the configured secret access key when resolving S3 credentials for connection checks or sync operations +- [Google Drive](../deep-dive/modules/gdrive) module: manages a secret named `sync-engine-gdrive-refresh-token` automatically and uses that for authorization for connection checks and sync operations. - [Encryption](../deep-dive/modules/encryption) module: reads the configured encryption password secret at the start of a sync run for later encryption and decryption Sync Engine and official modules never read beyond what is provided by the user. diff --git a/docs/src/pages/en/usage/remote-migration.md b/docs/src/pages/en/usage/remote-migration.md new file mode 100644 index 00000000..f2d1dbfc --- /dev/null +++ b/docs/src/pages/en/usage/remote-migration.md @@ -0,0 +1,22 @@ +# Migrate Your Cloud Storage + +Some features, like [Asymmetric Storage](../deep-dive/asymmetric-storage) and [Encryption](../deep-dive/modules/encryption), require your remote storage be in a different format. When you toggle those features but the remote is still in an incompatible format, Sync Engine could produce catastrophic sync results. + +Sync Engine itself does not try to migrate the remote format automatically. This is because sync strategies may differ across devices, and a Sync Engine installation on one device cannot reliably perform a migration without leaving the remote or other devices in a broken partial state. This might be improved in the future, but a manual migration is currently necessary when you toggle those features. + +This page elaborates how to safely perform a manual migration with Sync Engine. + +## Steps + +1. Perform synchronizations on all your devices to ensure that they all have the latest set of files. +2. Choose one of your devices that has the most files and best network as your primary operating device, exit Obsidian on other devices. +3. Temporarily turn off realtime and scheduled syncing on that device to prevent accidental interference. +4. Adjust [sync strategies](./settings#sync-strategy) temporarily and perform syncs to ensure that the vault on this device has the **complete set of files** you need to upload to remote. When done, finally adjust sync strategies to ensure that **"Mirror local" strategy is applied to this whole set of files**. +5. **Find [Clear records](./settings#clear-records) in development settings and click "Clear"**. +6. Copy the entire set of files to a different place as backup. +7. Go to the file management interface of your cloud storage, **manually delete all previously synchronized files, or remove the base directory directly**. +8. Toggle the feature that changes the remote format (like encryption or asymmetric storage), a migration pop-up reminder should be shown, click "Confirm". +9. Now trigger a manual sync, a preview full of upload operations should be shown. Review the files to upload and start syncing, wait for the mass upload to complete. +10. **Turn off all network connections on other devices and enter Obsidian on each device**, toggle the same feature that you want to migrate for. +11. Restore network connections on these devices, and start a manual trial sync each. The sync should complete as already synchronized with no additional operations to perform. +12. Restore sync strategies and realtime / scheduled sync settings on your primary device. Then the migration is complete. diff --git a/docs/src/pages/en/usage/settings.md b/docs/src/pages/en/usage/settings.md index ffc7ab8f..b675b106 100644 --- a/docs/src/pages/en/usage/settings.md +++ b/docs/src/pages/en/usage/settings.md @@ -22,13 +22,16 @@ Automatically update installed modules from their configured sources. Enabled by ### Sync Strategy -Choose how Sync Engine decides what to do when local and remote files differ: +Strategy rules decide what Sync Engine does with each file and folder. Each rule pairs a glob expression with a strategy: - **Bidirectional** is the default. It can apply changes on both sides and asks the selected conflict resolver to handle simultaneous file changes. - **Mirror local** makes local vault authoritative. It copies local entries to remote and removes remote-only entries. - **Mirror remote** makes remote storage authoritative. It copies remote entries to local and removes local-only entries. +- **Don't sync** leaves matching files and folders out of synchronization entirely. -Mirror strategies overwrite changes on the non-authoritative side without conflict resolution. Other strategies may be supplied by modules. +Rules form an ordered list where the last matching rule wins. See [Sync Strategy Rules](#sync-strategy-rules) for how rules are evaluated and written. + +Mirror strategies overwrite changes on the non-authoritative side without conflict resolution. Modules can supply additional strategies; they appear in the rule's strategy dropdown once installed. ### Conflict Resolve Strategy @@ -76,9 +79,8 @@ This changes how the remote storage looks. Read the separate [Asymmetric Storage - Remote files will not be readable in their normal folder structure. - Every device using the vault must use the same setting. -- Changing this setting for an existing vault opens a migration prompt. Cancel leaves the current setting unchanged. -- **Toggle without migration** changes the setting without moving remote files or clearing records. Use it only when the remote storage already has the target layout, such as after migrating on another device. -- **Start migration** updates local state, clears matching records, removes known remote entries, and repopulates the remote storage with the new layout. Do not start it concurrently on multiple devices. +- Changing this setting for an existing vault opens a confirmation prompt. Cancel leaves the current setting unchanged. +- Sync Engine does not migrate remote storage automatically. Confirming applies the setting to new syncs only; existing remote files keep the old layout. Read the [Remote Migration guide](./remote-migration) before confirming. - Enable it only when you do not need to browse the remote files as ordinary files. Enabled by default. @@ -118,33 +120,28 @@ Enabled by default with a limit of `100MB`. - Numeric values such as request concurrency accept a number, for example `50`. - Values must be zero or greater. Settings that represent a limit or interval may reject zero. Invalid values remain marked with a warning and are not saved until corrected. -## Inclusion and Exclusion Rules - -Use glob rules to control which files and folders Sync Engine synchronizes. +## Sync Strategy Rules -- **Exclusion rules** stop matching files and folders from syncing. -- **Inclusion rules** make exceptions to exclusion rules. -- Files and folders matching neither list sync normally. +Rules are managed on the [Sync Strategy](#sync-strategy) page as an ordered list. Each rule pairs a glob expression with a strategy. When several rules match the same path, the **last matching rule wins**. Paths that match no rule are not synchronized. -Sync Engine evaluates each file and folder against both lists: +Sync Engine ships with default rules: bidirectional sync for everything (`*`), plus **Don't sync** rules for common noise such as `.git`, `.trash`, `.obsidian`, system metadata files, Office temporary files, and Sync Engine's own modules folder. You can edit, reorder, or extend these rules. -1. A matching inclusion rule takes precedence, even when an exclusion rule also matches. -2. An item inside an excluded folder remains excluded unless an inclusion rule matches that item or a descendant that should be kept. -3. An item matching only an exclusion rule does not sync. -4. An item matching neither list syncs. +- A rule that matches a folder applies its strategy to everything inside the folder. +- A later rule can single out paths inside such a folder and give them a different strategy. -Example: +Example: synchronize everything, keep a private folder out of sync, but keep one shared subfolder syncing: -```text -Exclusion rule: private/ -Inclusion rule: private/keep.md -``` +| Order | Rule | Strategy | +| ----- | --------------------- | ------------- | +| 1 | `*` | Bidirectional | +| 2 | `private/` | Don't sync | +| 3 | `private/shared/**/*` | Bidirectional | -`private/keep.md` syncs; other files under `private/` remain excluded. Add inclusion rules for files or subtrees that should pass through excluded folders. +Rule 2 excludes everything under `private/`. Rule 3 comes later, so files under `private/shared/` sync anyway. ::: warning -If you sync Obsidian's plugin directory, you need to **exclude Sync Engine's modules folder in any situation (always ensure `.obsidian/plugins/sync-engine/modules` exists in your exclusion rules)**. [Due to security considerations](../deep-dive/extensibility), all untracked modifications in that folder (including sync runs) will trigger Sync Engine's security protection mechanism. And will cause the modules fail to load. +Sync Engine's module files live in `.obsidian/plugins/sync-engine/modules`. If your rules make that folder sync, any modification Sync Engine does not know about (including changes made by sync runs) triggers [the security protection mechanism](../deep-dive/extensibility), and modules stop loading. The default rules exclude `.obsidian` entirely; keep that exclusion, or make sure your rules never sync the modules folder. ::: @@ -158,16 +155,24 @@ Rules use [`glob` expressions]( { setting - .setClass('sync-engine-togglable-value') + .setClass('sync-engine-column') .addComponent((element) => new SecretComponent(app, element) .setValue(settings.password) @@ -46,7 +46,7 @@ export default function encryptionSetting( }), ) .addToggle((toggle) => - setNeedMigration(ctx as Context, { + setNeedMigration(ctx, { apply: (value) => { settings.enabled = value; void saveSettings(); diff --git a/packages/gdrive/src/gdrive/check-connection.ts b/packages/gdrive/src/gdrive/check-connection.ts index bae7ca6a..b115f269 100644 --- a/packages/gdrive/src/gdrive/check-connection.ts +++ b/packages/gdrive/src/gdrive/check-connection.ts @@ -1,20 +1,16 @@ -import type { CheckConnectionResult, Request } from '@hesprs/sync-engine-sdk'; -import { getMessage } from '@repo/shared/error'; +import type { Request } from '@hesprs/sync-engine-sdk'; +import { toError } from '@repo/shared/error'; import { DRIVE_API, buildUrl, parseDriveError } from './api'; -export default async function checkConnection(request: Request): Promise { +export default async function checkConnection(request: Request): Promise { try { const response = await request(buildUrl(DRIVE_API, '/about', { fields: 'storageQuota' }), { method: 'GET', throw: false, }); - if (response.status >= 200 && response.status < 300) return { success: true } as const; - return { - reason: parseDriveError(response) ?? `HTTP ${response.status}`, - success: false, - } as const; + if (response.status >= 200 && response.status < 300) return; + return new Error(parseDriveError(response) ?? `HTTP ${response.status}`); } catch (error) { - const errorMessage = getMessage(error); - return { reason: errorMessage, success: false } as const; + return toError(error); } } diff --git a/packages/gdrive/src/gdrive/fs.ts b/packages/gdrive/src/gdrive/fs.ts index 62c464cc..89b34417 100644 --- a/packages/gdrive/src/gdrive/fs.ts +++ b/packages/gdrive/src/gdrive/fs.ts @@ -118,7 +118,7 @@ export default class GdriveFs implements RootFs { { method: 'GET' }, ); const id = response.json().files?.[0]?.id; - if (!id) return undefined; + if (!id) return; this.ids.set(childKey, id); parentId = id; prefix = childKey; @@ -234,6 +234,7 @@ export default class GdriveFs implements RootFs { async move(oldKey: string, newKey: string): Promise { const id = this.resolveId(oldKey); if (id === undefined) throw notFoundError(oldKey); + const existingId = this.resolveId(newKey); const oldParentId = this.resolveId(dirname(oldKey)); const newParentId = this.resolveId(dirname(newKey)); if (!newParentId) throw new Error(`Parent not created when moving to "${newKey}"!`); @@ -242,11 +243,14 @@ export default class GdriveFs implements RootFs { query.addParents = newParentId; if (oldParentId !== undefined) query.removeParents = oldParentId; } - await this.requestOrThrow(buildUrl(DRIVE_API, `/files/${id}`, query), { - body: textToUint8Array(JSON.stringify({ name: basename(newKey) })), - headers: { 'Content-Type': 'application/json; charset=UTF-8' }, - method: 'PATCH', - }); + await Promise.all([ + this.requestOrThrow(buildUrl(DRIVE_API, `/files/${id}`, query), { + body: textToUint8Array(JSON.stringify({ name: basename(newKey) })), + headers: { 'Content-Type': 'application/json; charset=UTF-8' }, + method: 'PATCH', + }), + existingId ? this.delete(newKey) : Promise.resolve(), // GDrive can create duplicated keys + ]); this.dropCache(oldKey); } diff --git a/packages/gdrive/src/setting.ts b/packages/gdrive/src/setting.ts index ca632e7d..5962f037 100644 --- a/packages/gdrive/src/setting.ts +++ b/packages/gdrive/src/setting.ts @@ -11,7 +11,7 @@ import type { } from '@hesprs/sync-engine-sdk'; import type { App, SettingGroupItem } from 'obsidian'; import { s } from '@hesprs/sync-engine-sdk'; -import { getMessage } from '@repo/shared/error'; +import { describeError, toError } from '@repo/shared/error'; import { normalizeBaseDir } from '@repo/shared/path'; import { Modal, Notice, Setting } from 'obsidian'; import type { TokenManager } from './gdrive/auth'; @@ -143,9 +143,9 @@ export default function gdriveSetting( } } catch (error) { if (cancelled) return; - const reason = getMessage(error); - new Notice(translate('authorizationFailed', reason), 5); - dispatch('errorGeneral', `Google Drive auth failed: \`${reason}\`.`); + const parsedError = toError(error); + new Notice(translate('authorizationFailed', parsedError.message), 5); + dispatch('errorGeneral', describeError(parsedError, `Google Drive auth failed`)); } finally { resolve(); } diff --git a/packages/i18n/src/ru/translations.ts b/packages/i18n/src/ru/translations.ts index f2cab17e..8257c7cd 100644 --- a/packages/i18n/src/ru/translations.ts +++ b/packages/i18n/src/ru/translations.ts @@ -18,12 +18,11 @@ const pc = (count: number, one: string, few: string, many: string) => `${count} ${p(count, one, few, many)}`; const ru: Translations = { - addExclusionRule: 'Добавить правило исключения', addHeader: 'Добавить заголовок', - addInclusionRule: 'Добавить правило включения', addRecord: 'Добавить запись', addSecretHeader: 'Добавить секретный заголовок', addSource: 'Добавить источник', + addStrategy: 'Добавить стратегию', asymmetricStorage: 'Асимметричное хранилище', asymmetricStorageDescription: () => createFragment((frag) => { @@ -53,7 +52,7 @@ const ru: Translations = { text: 'После включения убедитесь, что асимметричное хранилище активировано на всех устройствах.', }); ol.createEl('li', { - text: 'Миграция необходима, если это хранилище ранее загружалось без асимметричного хранилища.', + text: 'Миграция необходима, если это хранилище ранее синхронизировалось без асимметричного хранилища.', }); } else { frag.createEl('p', { @@ -67,7 +66,7 @@ const ru: Translations = { text: 'Убедитесь, что асимметричное хранилище отключено на всех устройствах.', }); ol.createEl('li', { - text: 'Миграция необходима, если это хранилище ранее загружалось с включённым асимметричным хранилищем.', + text: 'Миграция необходима, если это хранилище ранее синхронизировалось с включённым асимметричным хранилищем.', }); } }), @@ -77,10 +76,10 @@ const ru: Translations = { awaitingConfirmation: 'Ожидание подтверждения', backend: 'Бэкенд хранилища', backendDescription: 'Выберите облачный сервис. Бэкенды предоставляются модулями.', + backendNotInstalled: (name) => `Бэкенд «${name}» не установлен!`, bidirectional: 'Двунаправленная', cancel: 'Отмена', cancelled: 'Отменено', - caseSensitive: 'С учётом регистра', checkConnection: 'Проверить соединение', checkConnectionFailed: 'Ошибка проверки соединения', checkConnectionSuccess: 'Соединение успешно проверено', @@ -119,6 +118,8 @@ const ru: Translations = { conflictResolveStrategy: 'Стратегия разрешения конфликтов', conflictResolveStrategyDescription: 'Выберите способ разрешения конфликтов, когда и удалённый, и локальный файл были изменены с момента последней синхронизации. Дополнительные стратегии доступны в модулях.', + conflictResolveStrategyNotInstalled: (strategy) => + `Стратегия разрешения конфликтов «${strategy}» не установлена!`, controls: 'Управление', createLocalDir: 'Создать локальную папку', createRemoteDir: 'Создать удалённую папку', @@ -134,6 +135,7 @@ const ru: Translations = { diffMatchPatch: 'Объединить', disableModule: 'Отключить модуль', done: 'Готово', + dontSync: 'Не синхронизировать', download: 'Скачать', downloadModule: 'Скачать модуль', edit: 'Редактировать', @@ -141,22 +143,6 @@ const ru: Translations = { enable: 'Включить', enableDescription: 'Определяет, следует ли загружать этот модуль.', enableModule: 'Включить модуль', - exclusionRules: 'Правила исключения', - exclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - 'Файлы и папки, соответствующие этим glob-шаблонам, не будут синхронизироваться. Не забудьте указать расширения файлов (например, ', - ); - frag.createEl('code', { text: '.md' }); - frag.appendText('), если хотите исключить файлы. См. '); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: 'документацию по настройкам', - }); - frag.appendText(' для руководства по настройке.'); - }), executing: 'Выполняется', export: 'Экспорт', exportLogsDescription: @@ -171,8 +157,7 @@ const ru: Translations = { failedToFetchSource: (url) => `Не удалось получить источник из «${url}»`, failedToLoadModule: (name) => `Не удалось загрузить модуль «${name}»`, features: 'Возможности', - filterPlaceholder: 'Например, temp.md, .trash/**/*', - filterRules: 'Правила фильтрации', + globPlaceholder: 'Например, temp.md, .trash/**/*', headerKeyPlaceholder: 'Ключ заголовка', headerValuePlaceholder: 'Значение заголовка', hide: 'Скрыть', @@ -190,20 +175,6 @@ const ru: Translations = { }), iconPlaceholder: 'Введите код иконки (например, puzzle)', idle: 'В ожидании', - inclusionRules: 'Правила включения', - inclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - 'Файлы и папки, подпадающие под правила исключения, но соответствующие этим glob-шаблонам, всё равно будут синхронизированы. См. ', - ); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: 'документацию по настройкам', - }); - frag.appendText(' для руководства по настройке.'); - }), installModuleFromFile: 'Установить модуль из файла', installed: 'Установлено', integrityVerification: 'Проверка целостности', @@ -233,13 +204,18 @@ const ru: Translations = { maxRequestConcurrencyDescription: 'Ограничить количество параллельных запросов во время синхронизации. Полезно для сервисов с лимитом на частоту запросов. Измените лимит в поле ниже.', maxRequestConcurrencyPlaceholder: 'Введите лимит запросов', - migrationDescription: - 'Миграция может занять от нескольких секунд до нескольких минут в зависимости от размера хранилища. Если вы уже выполнили миграцию удалённого хранилища на других устройствах, этот шаг можно пропустить.\n\nНачать миграцию сейчас?', - migrationFailed: 'Ошибка миграции', - migrationPhase1Description: 'Проверка актуальности локального состояния', - migrationPhase2Description: 'Очистка удалённого хранилища и записей', - migrationPhase3Description: 'Заполнение удалённого хранилища новой структурой', - migrationProcess: 'Процесс миграции', + migrationInstruction: () => + createFragment((frag) => { + const p1 = frag.createEl('p', { text: 'См. ' }); + p1.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/remote-migration' }, + text: 'страницу документации', + }); + p1.appendText( + ' с инструкциями по безопасной ручной миграции. Для безопасности ваших данных Sync Engine не выполняет миграцию автоматически. Если вы уже мигрировали облачное хранилище, это предупреждение можно проигнорировать.', + ); + frag.createEl('p', { text: 'Подтвердить переключение?' }); + }), minRequestInterval: 'Мин. интервал между запросами', minRequestIntervalDescription: 'Задать минимальный интервал между последовательными запросами во время синхронизации. Полезно для сервисов с ограничением частоты запросов. Измените интервал в поле ниже.', @@ -273,14 +249,15 @@ const ru: Translations = { noInstalledModulesFound: 'Установленные модули не найдены.', noMatchingModulesFound: 'Подходящие модули не найдены.', noModulesAvailable: 'Нет доступных модулей.', - noRuleConfigured: 'Правило не настроено.', noSourceConfigured: 'Источник не настроен.', + noStrategyConfigured: 'Стратегия не настроена.', none: 'Нет', noticeStatusOnMobile: 'Уведомления о статусе на мобильных устройствах', noticeStatusOnMobileDescription: 'Отображать всплывающее уведомление на мобильных устройствах во время синхронизации. Заменяет строку состояния, используемую на ПК.', official: 'Официальный', openReadme: 'Открыть страницу README модуля.', + pleaseSetBackend: 'Пожалуйста, выберите бэкенд!', readmePage: 'Страница README', readmePageDescription: 'Страница README модуля необязательна; пустое поле означает, что README нет.', @@ -332,7 +309,6 @@ const ru: Translations = { speed: 'Скорость', speedLabelDescription: 'Правильная настройка этого параметра может повысить скорость синхронизации.', - startMigration: 'Начать миграцию', startNonInteractiveSync: 'Запустить синхронизацию без подтверждений', startSync: 'Запустить синхронизацию', startupSync: 'Синхронизация при запуске', @@ -342,9 +318,18 @@ const ru: Translations = { stopSync: 'Остановить синхронизацию', syncProgress: 'Прогресс синхронизации', syncStrategy: 'Стратегия синхронизации', - syncStrategyDescription: - 'Выберите стратегию синхронизации для обработки изменений в файлах. Дополнительные стратегии доступны в модулях.', - toggleWithoutMigration: 'Переключить без миграции', + syncStrategyDescription: () => + createFragment((frag) => { + frag.appendText( + 'Настройте разные стратегии синхронизации для разных файлов на основе правил Glob. Если с одним файлом совпадают несколько правил, применяется последнее из них. См. ', + ); + frag.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/settings#sync-strategy' }, + text: 'страницу документации', + }); + frag.appendText('.'); + }), + syncStrategyNotInstalled: (strategy) => `Стратегия синхронизации «${strategy}» не установлена!`, untrustedModule: 'Модуль из недоверенного источника', untrustedModuleDescription: ({ fileName, size, path, mtime, ctime }) => createFragment((frag) => { @@ -377,10 +362,10 @@ const ru: Translations = { li5.createEl('code', { text: mtime }); const p2 = frag.createEl('p'); p2.createEl('strong', { - text: 'Пожалуйста, не включайте модули из неизвестных источников.', + text: 'Для предотвращения выполнения вредоносного кода Sync Engine теперь требуется ваше явное согласие. ', }); p2.appendText( - 'Если вы не знаете, откуда появился этот модуль, лучше всего сразу удалить его. Если модуль под вашим контролем, вы можете выбрать «Настроить» и включить его. Пояснение к этому предупреждению см. в ', + 'Вы можете выбрать «Настроить» и включить его, если вы контролируете этот модуль, или удалить, если не знаете, откуда он. Пояснение к этому предупреждению см. в ', ); p2.createEl('a', { attr: { href: 'https://sync.consensia.cc/deep-dive/extensibility' }, diff --git a/packages/i18n/src/zh-TW/translations.ts b/packages/i18n/src/zh-TW/translations.ts index b614ee68..3ca3d1dc 100644 --- a/packages/i18n/src/zh-TW/translations.ts +++ b/packages/i18n/src/zh-TW/translations.ts @@ -1,12 +1,11 @@ import type { Translations } from '@hesprs/sync-engine-sdk'; const zhTW: Translations = { - addExclusionRule: '新增排除規則', addHeader: '新增標頭', - addInclusionRule: '新增包含規則', addRecord: '新增紀錄', addSecretHeader: '新增加密標頭', addSource: '新增來源', + addStrategy: '新增策略', asymmetricStorage: '非對稱儲存', asymmetricStorageDescription: () => createFragment((frag) => { @@ -30,7 +29,7 @@ const zhTW: Translations = { }); ol.createEl('li', { text: '啟用後,請確保所有裝置皆已開啟非對稱儲存。' }); ol.createEl('li', { - text: '若此儲存庫先前未啟用非對稱儲存即進行過上傳,則必須執行遷移。', + text: '若此儲存庫先前未啟用非對稱儲存即進行過同步,則必須執行遷移。', }); } else { frag.createEl('p', { text: '在停用非對稱儲存前,請務必留意以下幾點:' }); @@ -38,7 +37,7 @@ const zhTW: Translations = { ol.createEl('li', { text: '後續的所有上傳將會還原為本地的層級結構。' }); ol.createEl('li', { text: '請確保所有裝置皆已停用非對稱儲存。' }); ol.createEl('li', { - text: '若此儲存庫先前是在啟用非對稱儲存的狀態下上傳,則必須執行遷移。', + text: '若此儲存庫先前是在啟用非對稱儲存的狀態下同步,則必須執行遷移。', }); } }), @@ -47,10 +46,10 @@ const zhTW: Translations = { awaitingConfirmation: '等待確認', backend: '儲存後端', backendDescription: '選擇要使用的雲端服務。後端支援由模組提供。', + backendNotInstalled: (name) => `未安裝後端「${name}」!`, bidirectional: '雙向同步', cancel: '取消', cancelled: '已取消', - caseSensitive: '區分大小寫', checkConnection: '測試連線', checkConnectionFailed: '連線測試失敗', checkConnectionSuccess: '連線測試成功', @@ -85,6 +84,7 @@ const zhTW: Translations = { conflictResolveStrategy: '衝突解決策略', conflictResolveStrategyDescription: '選擇當遠端與本地檔案自上次同步後皆有修改時的解決方式。更多策略可透過模組提供。', + conflictResolveStrategyNotInstalled: (strategy) => `衝突解決策略 “${strategy}” 未安裝!`, controls: '控制項', createLocalDir: '建立本地資料夾', createRemoteDir: '建立遠端資料夾', @@ -100,6 +100,7 @@ const zhTW: Translations = { diffMatchPatch: '文字合併', disableModule: '停用模組', done: '完成', + dontSync: '不同步', download: '下載', downloadModule: '下載模組', edit: '編輯', @@ -107,22 +108,6 @@ const zhTW: Translations = { enable: '啟用', enableDescription: '設定是否載入此模組。', enableModule: '啟用模組', - exclusionRules: '排除規則', - exclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - '符合這些 Glob 萬用字元模式的檔案或資料夾將不會進行同步。若要排除特定檔案,請記得加上副檔名(例如 ', - ); - frag.createEl('code', { text: '.md' }); - frag.appendText(')。請參閱 '); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: '設定文件', - }); - frag.appendText('以瞭解設定指南。'); - }), executing: '執行中', export: '匯出', exportLogsDescription: '將外掛程式紀錄匯出至儲存庫中的檔案。請在欄位中設定紀錄匯出目錄。', @@ -135,8 +120,7 @@ const zhTW: Translations = { failedToFetchSource: (url) => `無法從 "${url}" 取得來源`, failedToLoadModule: (name) => `載入模組 "${name}" 失敗`, features: '功能特徵', - filterPlaceholder: '例如 temp.md, .trash/**/*', - filterRules: '過濾規則', + globPlaceholder: '例如 temp.md, .trash/**/*', headerKeyPlaceholder: '標頭名稱', headerValuePlaceholder: '標頭數值', hide: '隱藏', @@ -152,20 +136,6 @@ const zhTW: Translations = { }), iconPlaceholder: '輸入圖示代碼(例如 puzzle)', idle: '待命', - inclusionRules: '包含規則', - inclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - '即使符合排除規則,只要符合這些 Glob 萬用字元模式的檔案或資料夾仍會進行同步。請參閱 ', - ); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: '設定文件', - }); - frag.appendText('以瞭解設定指南。'); - }), installModuleFromFile: '從檔案安裝模組', installed: '已安裝', integrityVerification: '完整性驗證', @@ -193,13 +163,18 @@ const zhTW: Translations = { maxRequestConcurrencyDescription: '限制同步過程中的同時請求數量。此選項適用於有請求速率限制的服務。請在欄位中修改併發限制。', maxRequestConcurrencyPlaceholder: '輸入併發限制', - migrationDescription: - '根據儲存庫的大小,遷移過程可能需要數秒至數分鐘。若您已在其他裝置上完成遠端遷移,可以跳過此步驟。\n\n是否立即開始遷移?', - migrationFailed: '遷移失敗', - migrationPhase1Description: '確保本地狀態為最新', - migrationPhase2Description: '清理遠端檔案與紀錄', - migrationPhase3Description: '以新結構建置遠端內容', - migrationProcess: '遷移進度', + migrationInstruction: () => + createFragment((frag) => { + const p1 = frag.createEl('p', { text: '請參閱' }); + p1.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/remote-migration' }, + text: '文件頁面', + }); + p1.appendText( + '了解如何安全地手動執行遷移。基於資料安全考量,Sync Engine 不會自動執行遷移。若您已遷移過雲端儲存,可以忽略此警告。', + ); + frag.createEl('p', { text: '確認切換?' }); + }), minRequestInterval: '最小請求間隔', minRequestIntervalDescription: '限制同步過程中連續請求之間的最小時間間隔。此選項適用於有請求速率限制的服務。請在欄位中修改間隔時間。', @@ -232,13 +207,14 @@ const zhTW: Translations = { noInstalledModulesFound: '未找到已安裝的模組。', noMatchingModulesFound: '未找到符合條件的模組。', noModulesAvailable: '無可用模組。', - noRuleConfigured: '尚未設定規則。', noSourceConfigured: '尚未設定來源。', + noStrategyConfigured: '尚未設定策略。', none: '無', noticeStatusOnMobile: '行動裝置同步狀態通知', noticeStatusOnMobileDescription: '同步進行時於行動裝置上顯示通知訊息(取代桌面版的狀態列)。', official: '官方', openReadme: '開啟模組的 README 頁面。', + pleaseSetBackend: '請先設定後端!', readmePage: 'README 頁面', readmePageDescription: '設定模組的選用 README 頁面,留空表示無 README。', readmePagePlaceholder: 'https://example.com/my-module', @@ -285,7 +261,6 @@ const zhTW: Translations = { '由於 Sync Engine 外掛程式版本過舊,部分模組已隱藏。請更新外掛程式以查看完整模組目錄。', speed: '速度', speedLabelDescription: '正確設定此選項可能會提升同步速度。', - startMigration: '開始遷移', startNonInteractiveSync: '啟動非互動式同步', startSync: '開始同步', startupSync: '啟動時同步', @@ -295,8 +270,18 @@ const zhTW: Translations = { stopSync: '停止同步', syncProgress: '同步進度', syncStrategy: '同步策略', - syncStrategyDescription: '選擇用來處理檔案變更的同步策略。更多策略可透過模組提供。', - toggleWithoutMigration: '直接切換(不執行遷移)', + syncStrategyDescription: () => + createFragment((frag) => { + frag.appendText( + '根據 Glob 規則為不同的檔案設定不同的同步策略。當有多個規則配對至同一個檔案時,以最後一個規則為準。詳見', + ); + frag.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/settings#sync-strategy' }, + text: '文件頁面', + }); + frag.appendText('。'); + }), + syncStrategyNotInstalled: (strategy) => `同步策略 “${strategy}” 未安裝!`, untrustedModule: '非信任模組', untrustedModuleDescription: ({ fileName, size, path, mtime, ctime }) => createFragment((frag) => { @@ -328,9 +313,11 @@ const zhTW: Translations = { li5.appendText('修改時間:'); li5.createEl('code', { text: mtime }); const p2 = frag.createEl('p'); - p2.createEl('strong', { text: '請避免啟用來源不明的模組。' }); + p2.createEl('strong', { + text: '為了防止惡意程式碼執行,Sync Engine 現需取得您的明確同意。', + }); p2.appendText( - '如果您不知道它來自何處,請直接將其刪除;如果它由您掌控,您可以選擇「設定」並加以啟用。如需了解此警告的說明,請參閱 ', + '若該模組在您的控制範圍內,您可以選擇「設定」並將其啟用;若您不確定其來源,亦可將其刪除。關於此警告的詳細說明,請參閱 ', ); p2.createEl('a', { attr: { href: 'https://sync.consensia.cc/deep-dive/extensibility' }, diff --git a/packages/i18n/src/zh/translations.ts b/packages/i18n/src/zh/translations.ts index b5aef963..16a04254 100644 --- a/packages/i18n/src/zh/translations.ts +++ b/packages/i18n/src/zh/translations.ts @@ -1,12 +1,11 @@ import type { Translations } from '@hesprs/sync-engine-sdk'; const zh: Translations = { - addExclusionRule: '添加排除规则', addHeader: '添加请求头', - addInclusionRule: '添加包含规则', addRecord: '添加记录', addSecretHeader: '添加机密请求头', addSource: '添加源', + addStrategy: '添加策略', asymmetricStorage: '非对称存储', asymmetricStorageDescription: () => createFragment((frag) => { @@ -30,7 +29,7 @@ const zh: Translations = { ol.createEl('li', { text: '如果您需要远程端保持人类可读性,请不要启用此功能。' }); ol.createEl('li', { text: '启用后,请确保所有设备都已启用非对称存储。' }); ol.createEl('li', { - text: '如果该库此前在未启用非对称存储的情况下上传过,则必须进行迁移。', + text: '如果该库此前在未启用非对称存储的情况下同步过,则必须进行迁移。', }); } else { frag.createEl('p', { text: '在禁用非对称存储之前,您需要注意以下几点:' }); @@ -38,7 +37,7 @@ const zh: Translations = { ol.createEl('li', { text: '后续的所有上传都将镜像本地的层级结构。' }); ol.createEl('li', { text: '请确保所有设备都已禁用非对称存储。' }); ol.createEl('li', { - text: '如果该库此前在启用非对称存储的情况下上传过,则必须进行迁移。', + text: '如果该库此前在启用非对称存储的情况下同步过,则必须进行迁移。', }); } }), @@ -47,10 +46,10 @@ const zh: Translations = { awaitingConfirmation: '等待确认', backend: '存储后端', backendDescription: '选择要使用的云服务。后端由模块提供。', + backendNotInstalled: (name) => `未安装后端“${name}”!`, bidirectional: '双向同步', cancel: '取消', cancelled: '已取消', - caseSensitive: '区分大小写', checkConnection: '测试连接', checkConnectionFailed: '测试连接失败', checkConnectionSuccess: '测试连接成功', @@ -85,6 +84,7 @@ const zh: Translations = { conflictResolveStrategy: '冲突解决策略', conflictResolveStrategyDescription: '选择当本地和远程自上次同步以来都被修改过时,如何解决冲突。更多策略可以在模块中找到。', + conflictResolveStrategyNotInstalled: (strategy) => `冲突解决策略 “${strategy}” 未安装!`, controls: '控制', createLocalDir: '创建本地文件夹', createRemoteDir: '创建远程文件夹', @@ -100,6 +100,7 @@ const zh: Translations = { diffMatchPatch: '合并', disableModule: '禁用模块', done: '完成', + dontSync: '不同步', download: '下载', downloadModule: '下载模块', edit: '编辑', @@ -107,22 +108,6 @@ const zh: Translations = { enable: '启用', enableDescription: '设置是否加载此模块。', enableModule: '启用模块', - exclusionRules: '排除规则', - exclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - '匹配这些 Glob 模式的文件 / 文件夹将不会被同步。如果您想排除文件,请记得添加文件扩展名(例如 ', - ); - frag.createEl('code', { text: '.md' }); - frag.appendText(')。请参阅 '); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: '设置文档', - }); - frag.appendText('了解配置指南。'); - }), executing: '正在执行', export: '导出', exportLogsDescription: '将插件日志导出到仓库中的文件。请在输入框中设置日志导出目录。', @@ -135,8 +120,7 @@ const zh: Translations = { failedToFetchSource: (url) => `从 “${url}” 获取源失败`, failedToLoadModule: (name) => `加载模块 “${name}” 失败`, features: '功能', - filterPlaceholder: '例如 temp.md, .trash/**/*', - filterRules: '过滤规则', + globPlaceholder: '例如 temp.md, .trash/**/*', headerKeyPlaceholder: '请求头键', headerValuePlaceholder: '请求头值', hide: '隐藏', @@ -152,20 +136,6 @@ const zh: Translations = { }), iconPlaceholder: '输入图标代码(例如 puzzle)', idle: '空闲', - inclusionRules: '包含规则', - inclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - '匹配排除规则但同时也匹配这些 Glob 模式的文件 / 文件夹仍会被同步。请参阅 ', - ); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: '设置文档', - }); - frag.appendText('了解配置指南。'); - }), installModuleFromFile: '从文件安装模块', installed: '已安装', integrityVerification: '完整性验证', @@ -195,13 +165,18 @@ const zh: Translations = { maxRequestConcurrencyDescription: '限制同步过程中的同时请求数。此选项对于有请求频率限制的服务非常有用。在输入框中修改并发限制。', maxRequestConcurrencyPlaceholder: '输入并发限制', - migrationDescription: - '迁移可能需要几秒钟到几分钟不等,具体取决于库的大小。如果您已在其他设备上迁移了远程端,可以跳过此迁移。\n\n现在开始迁移吗?', - migrationFailed: '迁移失败', - migrationPhase1Description: '确保本地状态是最新的', - migrationPhase2Description: '清理远程端和记录', - migrationPhase3Description: '使用新结构填充远程端', - migrationProcess: '迁移进程', + migrationInstruction: () => + createFragment((frag) => { + const p1 = frag.createEl('p', { text: '请参阅' }); + p1.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/remote-migration' }, + text: '文档页面', + }); + p1.appendText( + '了解如何安全地手动执行迁移。出于数据安全考虑,Sync Engine 不会自动执行迁移。如果您已迁移过云端存储,可以忽略此警告。', + ); + frag.createEl('p', { text: '确认切换?' }); + }), minRequestInterval: '最小请求间隔', minRequestIntervalDescription: '限制同步过程中连续请求之间的最小时间间隔。此选项对于有请求频率限制的服务非常有用。在输入框中修改间隔。', @@ -234,14 +209,15 @@ const zh: Translations = { noInstalledModulesFound: '未找到已安装的模块。', noMatchingModulesFound: '未找到匹配的模块。', noModulesAvailable: '没有可用模块。', - noRuleConfigured: '未配置规则。', noSourceConfigured: '未配置源。', + noStrategyConfigured: '未配置策略。', none: '无', noticeStatusOnMobile: '移动端同步状态提示', noticeStatusOnMobileDescription: '同步进行时在移动设备上显示通知提示。在桌面端则会替换状态栏显示。', official: '官方', openReadme: '打开模块的 README 页面。', + pleaseSetBackend: '请先设置后端!', readmePage: 'README 页面', readmePageDescription: '设置模块的可选 README 页面,留空表示无 README。', readmePagePlaceholder: 'https://example.com/my-module', @@ -288,7 +264,6 @@ const zh: Translations = { '由于 Sync Engine 插件版本过旧,部分模块已隐藏。请更新插件以查看完整模块目录。', speed: '速度', speedLabelDescription: '正确配置此设置可能会提高同步速度。', - startMigration: '开始迁移', startNonInteractiveSync: '开始静默同步', startSync: '开始同步', startupSync: '启动同步', @@ -297,8 +272,18 @@ const zh: Translations = { stopSync: '停止同步', syncProgress: '同步进度', syncStrategy: '同步策略', - syncStrategyDescription: '选择用于解决文件更改的同步策略。更多策略可以在模块中找到。', - toggleWithoutMigration: '直接切换(不进行迁移)', + syncStrategyDescription: () => + createFragment((frag) => { + frag.appendText( + '根据 Glob 规则为不同的文件配置不同的同步策略。当多条规则匹配同一个文件时,以最后一条规则为准。详见', + ); + frag.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/settings#sync-strategy' }, + text: '文档页面', + }); + frag.appendText('。'); + }), + syncStrategyNotInstalled: (strategy) => `同步策略 “${strategy}” 未安装!`, untrustedModule: '非信任模块', untrustedModuleDescription: ({ fileName, size, path, mtime, ctime }) => createFragment((frag) => { @@ -330,9 +315,11 @@ const zh: Translations = { li5.appendText('修改时间:'); li5.createEl('code', { text: mtime }); const p2 = frag.createEl('p'); - p2.createEl('strong', { text: '请避免启用来自未知来源的模块。' }); + p2.createEl('strong', { + text: '为防止恶意代码执行,Sync Engine 现在需要获得您的明确同意。', + }); p2.appendText( - '如果您不知道它来自哪里,请直接将其删除;如果它在您的控制之下,您可以选择“配置”并启用它。有关此警告的说明,请参阅', + '如果您确认该模块在您的控制之下,可以选择“配置”并启用它;如果您不知道它来自哪里,可以选择将其删除。关于此警告的详细说明,请参阅 ', ); p2.createEl('a', { attr: { href: 'https://sync.consensia.cc/deep-dive/extensibility' }, diff --git a/packages/plugin/dist/dev.spec.d.ts b/packages/plugin/dist/dev.spec.d.ts index 49f84432..478fe745 100644 --- a/packages/plugin/dist/dev.spec.d.ts +++ b/packages/plugin/dist/dev.spec.d.ts @@ -1,4 +1,4 @@ -import { At as FileStat, Dt as RootFs, Ft as RecordStatsMap, It as Stat, Lt as StatsMap, Mt as MaybePromise, Ot as WrappedFs, Pt as RecordStat, Rt as Binary, d as RequestParam, f as RequestResponse, jt as FolderStat, lt as TaskNames, q as Decider, u as Request, yt as Fs } from "./index-JdV6c5O1.spec.js"; +import { At as FileStat, Dt as RootFs, Ft as RecordStatsMap, It as Stat, K as Decider, Lt as StatsMap, Mt as MaybePromise, Ot as WrappedFs, Pt as RecordStat, Rt as Binary, ct as TaskNames, d as RequestResponse, jt as FolderStat, l as Request, u as RequestParam, vt as Fs } from "./index-DbDrKV7S.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-JdV6c5O1.spec.d.ts b/packages/plugin/dist/index-DbDrKV7S.spec.d.ts similarity index 91% rename from packages/plugin/dist/index-JdV6c5O1.spec.d.ts rename to packages/plugin/dist/index-DbDrKV7S.spec.d.ts index 0a2f54bb..a9ce12ec 100644 --- a/packages/plugin/dist/index-JdV6c5O1.spec.d.ts +++ b/packages/plugin/dist/index-DbDrKV7S.spec.d.ts @@ -32,9 +32,9 @@ type RecordStat = { }; type StatsMap = Map; type RecordStatsMap = Map; -type GlobMatchRule = { +type GlobStrategy = { expr: string; - caseSensitive: boolean; + strategy: string; }; type Progress = { total: number; @@ -62,7 +62,8 @@ type RootFs = { exists(key: string): MaybePromise; list(key: string, reporter: ListReporter): MaybePromise>; }; -type ListReporter = (progress: Required) => MaybePromise<'include' | 'exclude' | 'advance'>; +type ListOptions = 'include' | 'exclude' | 'advance'; +type ListReporter = (progress: Required) => MaybePromise; type WrappedFs = RootFs & { original: Fs; }; @@ -203,7 +204,7 @@ declare class Storage { private readonly memoryDB; private readonly indexedDB; constructor(ctx: { - getNamespace: () => string; + getNamespace: () => string | Error; }); private readonly getRecordStore; private readonly deleteRecordStore; @@ -212,7 +213,7 @@ declare class Storage { readonly root: { clearRecordStores: () => Promise; deleteRecordStore: (namespace?: string) => MaybePromise; - getRecordStore: (namespace?: string) => RecordStore; + getRecordStore: (namespace?: N) => N extends string ? RecordStore : RecordStore | undefined; indexedDB: DatabaseAsync; memoryDB: { getStore(name: K): { @@ -392,7 +393,6 @@ type DeciderInput = { remoteStats: StatsMap; records: RecordStatsMap; taskFactory: TaskFactory; - logger: (log: string) => void; }; //#endregion //#region src/sync/tasks/CreateLocalDir.d.ts @@ -407,8 +407,8 @@ declare class EventBus { readonly events: { logSync: string; logGeneral: string; - errorSync: string; - errorGeneral: string; + errorSync: Error; + errorGeneral: Error; }; private readonly cleanupCallbacks; private readonly isIdle; @@ -418,6 +418,8 @@ declare class EventBus { private readonly getThisSync; private readonly putSyncLog; private readonly putGeneralLog; + private readonly putSyncError; + private readonly putGeneralError; private readonly subscribers; private readonly on; private readonly dispatch; @@ -451,6 +453,84 @@ declare class I18n { }; } //#endregion +//#region src/utils/glob-match.d.ts +type GlobMatchResult = { + strategy: string; + advance?: boolean; +}; +//#endregion +//#region src/modules/Sync.d.ts +type SyncTerminateReason = { + result: 'cancelled'; +} | { + result: 'completed'; +} | { + result: 'failed'; + error: Error; +} | { + result: 'noop'; +}; +type TaskInfo = { + name: TaskNames; + key: string; + prettyName: string; + isDir: boolean; +}; +type FailedTaskInfo = TaskInfo & { + error: Error; +}; +type RemoteLister = (info: Infras & { + reporter: ListReporter; +}) => MaybePromise>; +type SyncOptions = { + remoteLister?: RemoteLister; + conflictResolver?: ConflictResolver; + detectMoves?: boolean; + needConfirmTasks?: boolean; + needConfirmDeletion?: boolean; + syncStrategy?: Array; +}; +declare class Sync { + private readonly ctx; + constructor(ctx: { + dispatch: Dispatch; + initializeSync: () => Infras; + on: On; + translate: Translate; + getConflictResolver: () => ConflictResolver; + decideTasks: (input: DecideTasksInput) => Array; + }); + readonly events: { + syncStarted: { + isCancelled: Ref; + trigger: string; + }; + syncInitialized: Infras & { + match: (path: string) => GlobMatchResult; + }; + remoteWalkProgress: Progress; + syncTerminated: SyncTerminateReason; + requestConfirmDelete: Array; + requestConfirmTasks: Array; + syncCanceled: undefined; + taskCompleted: TaskInfo; + taskFailed: FailedTaskInfo; + executionStarted: Array; + }; + readonly settings: { + maxFileSize: TogglableValue; + syncStrategy: Array; + }; + private readonly postProcess; + private readonly confirmTasks; + private readonly confirmDeletion; + private readonly executeSync; + private readonly convertDeleteToUpload; + root: { + executeSync: (trigger: string, options?: SyncOptions) => Promise; + }; +} +//#endregion //#region src/modules/Observability.d.ts type SyncStage = 'none' | 'walkingRemote' | 'awaitingConfirmation' | 'executing' | 'completed' | 'completedNoop' | 'cancelled' | 'failed'; type AddRibbonIcon = (icon: IconName, title: string, callback: (evt: MouseEvent) => void) => HTMLElement; @@ -700,7 +780,7 @@ declare function reactivelyValidate({ text, parse, onSave, format, immediate onSave: (value: T) => void; immediate?: boolean; }): void; -declare function generateEditableList({ memoryDB, items, identifier, saveSettings, rerenderSettingTab, defaultValue, render, translations: { add, empty, heading }, extraButtons }: { +declare function generateEditableList({ memoryDB, items, identifier, saveSettings, rerenderSettingTab, defaultValue, render, translations: { add, empty, heading }, extraButtons, reorder }: { memoryDB: DatabaseSync; items: Array; identifier: string; @@ -714,6 +794,7 @@ declare function generateEditableList({ memoryDB, items, identifier, saveSett heading?: string; }; extraButtons?: Array<(button: ExtraButtonComponent, list: Array>, save: () => void) => void>; + reorder?: boolean; }): SettingDefinitionList; //#endregion //#region src/settings/controls.d.ts @@ -756,28 +837,14 @@ type DevelopmentSettingTranslations = { //#region src/components/MigrationModal.d.ts type MigrationModalTranslations = { cancel: string; + confirm: string; remoteMigration: string; - migrationProcess: string; - startMigration: string; - migrationDescription: string; - migrationPhase1Description: string; - migrationPhase2Description: string; - migrationPhase3Description: string; - toggleWithoutMigration: string; - migrationFailed: string; - completed: string; - hide: string; - done: string; -}; -type MigrationContext = { + migrationInstruction: Fragment; +}; +declare function setNeedMigration({ app, translate }: { app: App; - dispatch: Dispatch; translate: Translate; - requestSync: (trigger: string) => Promise; - initializeSync: () => Infras; - memoryDB: ExistingMemoryDB; -}; -declare function setNeedMigration(ctx: MigrationContext, { toggle, needMigration, content, apply }: { +}, { toggle, needMigration, content, apply }: { toggle: ToggleComponent; needMigration?: (value: boolean) => MaybePromise; content: (value: boolean) => string | DocumentFragment; @@ -803,21 +870,6 @@ type FeaturesSettingTranslations = { asymmetricStorageMigration: Fragment; } & MigrationModalTranslations; //#endregion -//#region src/settings/filter.d.ts -type FilterSettingTranslations = { - filterRules: string; - inclusionRules: string; - inclusionRulesDescription: Fragment; - exclusionRules: string; - exclusionRulesDescription: Fragment; - xConfigured: Snippet; - addInclusionRule: string; - addExclusionRule: string; - noRuleConfigured: string; - filterPlaceholder: string; - caseSensitive: string; -}; -//#endregion //#region src/sdk/prefix.d.ts declare function prefixWrapper(original: Fs, prefix: string): WrappedFs; //#endregion @@ -845,17 +897,22 @@ type HeadSettingTranslations = { backend: string; backendDescription: string; syncStrategy: string; - syncStrategyDescription: string; + syncStrategyDescription: Fragment; + globPlaceholder: string; checkConnectionFailed: string; checkConnectionSuccess: string; checkConnection: string; conflictResolveStrategy: string; conflictResolveStrategyDescription: string; xEnabled: Snippet; + xConfigured: Snippet; settingTips: Fragment<{ labels: Array; addLabel: typeof addLabel; }>; + addStrategy: string; + noStrategyConfigured: string; + dontSync: string; }; declare function addLabel(element: Element, { text, tooltip, color, textColor }: LabelDefinition): HTMLSpanElement; //#endregion @@ -972,7 +1029,7 @@ declare class Bootstrap { keepRemote: string; renameAndKeepBoth: string; skip: string; - } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & UntrustedModuleTranslations & FileTreeTranslations & ModulesTranslations; + } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & UntrustedModuleTranslations & FileTreeTranslations & ModulesTranslations; readonly settings: { maxMemoryConsumption: TogglableValue; maxRequestConcurrency: TogglableValue; @@ -1094,8 +1151,7 @@ declare class Scheduler { startupSync: TogglableValue; scheduledSync: TogglableValue; realtimeSync: TogglableValue; - exclusionRules: Array; - inclusionRules: Array; + syncStrategy: Array; avoidAutoSyncWhenOffline: boolean; }; private readonly requestSync; @@ -1129,84 +1185,6 @@ type Events = MergeSingleKey; type Settings = MergeSingleKey; type Translations = MergeSingleKey; //#endregion -//#region src/utils/glob-match.d.ts -type GlobMatchResult = 'include' | 'exclude' | 'advance' | 'probe'; -//#endregion -//#region src/modules/Sync.d.ts -type SyncTerminateReason = { - result: 'cancelled'; -} | { - result: 'completed'; -} | { - result: 'failed'; - error: string; -} | { - result: 'noop'; -}; -type TaskInfo = { - name: TaskNames; - key: string; - prettyName: string; - isDir: boolean; -}; -type FailedTaskInfo = TaskInfo & { - error: string; -}; -type RemoteLister = (info: Infras & { - reporter: ListReporter; -}) => MaybePromise>; -type SyncOptions = { - decider?: Decider; - remoteLister?: RemoteLister; - conflictResolver?: ConflictResolver; - detectMoves?: boolean; - needConfirmTasks?: boolean; - needConfirmDeletion?: boolean; - exclusionRules?: Array; - inclusionRules?: Array; -}; -declare class Sync { - private readonly ctx; - constructor(ctx: { - dispatch: Dispatch; - initializeSync: () => Infras; - getDecider: () => Decider; - on: On; - translate: Translate; - getConflictResolver: () => ConflictResolver; - }); - readonly events: { - syncStarted: { - isCancelled: Ref; - trigger: string; - }; - syncInitialized: Infras & { - match: (path: string) => GlobMatchResult; - }; - remoteWalkProgress: Progress; - syncTerminated: SyncTerminateReason; - requestConfirmDelete: Array; - requestConfirmTasks: Array; - syncCanceled: undefined; - taskCompleted: TaskInfo; - taskFailed: FailedTaskInfo; - executionStarted: Array; - }; - readonly settings: { - maxFileSize: TogglableValue; - exclusionRules: Array; - inclusionRules: Array; - }; - private readonly postProcess; - private readonly confirmTasks; - private readonly confirmDeletion; - private readonly executeSync; - private readonly convertDeleteToUpload; - root: { - executeSync: (trigger: string, options?: SyncOptions) => Promise; - }; -} -//#endregion //#region src/modules/Registrar.d.ts type RejectableWrapper = (value: T) => T | undefined; type OrderedWrapperEntry = { @@ -1216,16 +1194,10 @@ type OrderedWrapperEntry = { type RemoteRequestMiddlewareEntry = OrderedWrapperEntry; type LocalRequestMiddlewareEntry = OrderedWrapperEntry; type FsWrapperEntry = OrderedWrapperEntry; -type CheckConnectionResult = { - success: true; -} | { - success: false; - reason: string; -}; type RemoteFsEntry = { instantiate: (request: Request) => RootFs; prettyName: () => string; - checkConnection: (request: Request) => MaybePromise; + checkConnection: (request: Request) => MaybePromise; }; type DeciderEntry = { decider: Decider; @@ -1263,6 +1235,12 @@ type RequestResponse = { status: number; }; type Request = (url: string, params?: RequestParam) => Promise; +type DecideTasksInput = { + local: Record; + remote: Record; + record: Record; + taskFactory: TaskFactory; +}; declare class Registrar { private readonly ctx; private readonly cleanupCallbacks; @@ -1278,19 +1256,26 @@ declare class Registrar { private readonly conflictResolverRegistry; readonly settings: { remoteFs: string; - decider: string; conflictResolver: string; }; + readonly i18n: { + pleaseSetBackend: string; + backendNotInstalled: Snippet; + syncStrategyNotInstalled: Snippet; + conflictResolveStrategyNotInstalled: Snippet; + }; constructor(ctx: { app: App; - getRecordStore: (namespace?: string) => StoreAsync; + getRecordStore: (namespace: string) => RecordStore; + translate: Translate; + dispatch: Dispatch; }); private readonly getVaultRequest; private readonly createLocalFs; private readonly createRemoteFs; private readonly getRequest; private readonly getCheckConnection; - private readonly getDecider; + private readonly decideTasks; private readonly optimizeLocal; private readonly optimizeRemote; private readonly reduceTriggers; @@ -1300,12 +1285,12 @@ declare class Registrar { root: { conflictResolverRegistry: Map; createLocalFs: () => Fs; - createRemoteFs: (remoteFs?: string) => Fs; + createRemoteFs: (remoteFs?: string) => Fs | Error; + decideTasks: ({ local, record, remote, taskFactory }: DecideTasksInput) => BaseTask[]; deciderRegistry: Map; - getCheckConnection: (remoteFs?: string) => () => MaybePromise; + getCheckConnection: (remoteFs?: string) => () => MaybePromise; getConflictResolver: () => ConflictResolver; - getDecider: () => Decider; - getNamespace: (localFs?: Fs, remoteFs?: Fs) => string; + getNamespace: (localFs?: Fs, remoteFs?: R) => R extends Fs ? string : string | Error; getRequest: () => Request; getVaultRequest: () => VaultRequest; initializeSync: () => Infras; @@ -1383,4 +1368,4 @@ type VaultRequest = (key: string, params?: T) => Promise; type TrashOption = 'local' | 'system' | 'permanent'; //#endregion -export { RemoveRecord as $, setNeedMigration as A, FileStat as At, ObsidianLanguageCode as B, digOriginal as C, MoveAtom as Ct, readWithSize as D, RootFs as Dt, pipe as E, OutputAtom as Et, CallableOrObjectTree as F, RecordStatsMap as Ft, On as G, Translate as H, SettingEntry as I, Stat$1 as It, DeciderInput as J, CreateLocalDir as K, AugmentedModuleMeta as L, StatsMap as Lt, generateEditableList as M, MaybePromise as Mt, reactivelyValidate as N, Progress as Nt, writeWithValue as O, WrappedFs as Ot, s as P, RecordStat as Pt, RemoveRemote as Q, ModuleMeta as R, Binary as Rt, SelectFromContext as S, MkdirAtom as St, concurrency as T, OptimizerOutput as Tt, TranslationResource as U, Snippet as V, Dispatch as W, Upload as X, TaskFactory as Y, ResolveConflict as Z, Context as _, CustomAtom as _t, FsWrapperEntry as a, AddRecord as at, Translations as b, InputAtom as bt, RemoteFsEntry as c, ConflictResolverPayload as ct, RequestParam as d, DatabaseAsync as dt, RemoveLocal as et, RequestResponse as f, DatabaseSync as ft, SyncTerminateReason as g, BatchOptimizer as gt, SyncOptions as h, StoreSync as ht, DeciderEntry as i, CreateRemoteDir as it, LabelDefinition as j, FolderStat as jt, prefixWrapper as k, WriteAtom as kt, RemoteRequestMiddlewareEntry as l, TaskNames as lt, RemoteLister as m, StoreOperations as mt, CheckConnectionResult as n, MoveLocal as nt, LocalRequestMiddlewareEntry as o, BaseTask as ot, TriggerEntry as p, StoreAsync as pt, Decider as q, ConflictResolverEntry as r, Download as rt, OptimizerEntry as s, ConflictResolver as st, VaultRequest as t, MoveRemote as tt, Request as u, RecordStore as ut, Events as v, DeleteAtom as vt, chunkSize as w, OptimizerInput as wt, ExistingMemoryDB as x, ListReporter as xt, Settings as y, Fs as yt, Fragment as z }; \ No newline at end of file +export { RemoveLocal as $, s as A, FileStat as At, Snippet as B, readWithSize as C, MoveAtom as Ct, LabelDefinition as D, RootFs as Dt, setNeedMigration as E, OutputAtom as Et, RemoteLister as F, RecordStatsMap as Ft, CreateLocalDir as G, TranslationResource as H, SyncOptions as I, Stat$1 as It, TaskFactory as J, Decider as K, SyncTerminateReason as L, StatsMap as Lt, SettingEntry as M, MaybePromise as Mt, AugmentedModuleMeta as N, Progress as Nt, generateEditableList as O, WrappedFs as Ot, ModuleMeta as P, RecordStat as Pt, RemoveRecord as Q, Fragment as R, Binary as Rt, pipe as S, MkdirAtom as St, prefixWrapper as T, OptimizerOutput as Tt, Dispatch as U, Translate as V, On as W, ResolveConflict as X, Upload as Y, RemoveRemote as Z, ExistingMemoryDB as _, DeleteAtom as _t, LocalRequestMiddlewareEntry as a, BaseTask as at, chunkSize as b, ListOptions as bt, RemoteRequestMiddlewareEntry as c, TaskNames as ct, RequestResponse as d, DatabaseSync as dt, MoveRemote as et, TriggerEntry as f, StoreAsync as ft, Translations as g, CustomAtom as gt, Settings as h, BatchOptimizer as ht, FsWrapperEntry as i, AddRecord as it, CallableOrObjectTree as j, FolderStat as jt, reactivelyValidate as k, WriteAtom as kt, Request as l, RecordStore as lt, Events as m, StoreSync as mt, ConflictResolverEntry as n, Download as nt, OptimizerEntry as o, ConflictResolver as ot, Context as p, StoreOperations as pt, DeciderInput as q, DeciderEntry as r, CreateRemoteDir as rt, RemoteFsEntry as s, ConflictResolverPayload as st, VaultRequest as t, MoveLocal as tt, RequestParam as u, DatabaseAsync as ut, SelectFromContext as v, Fs as vt, writeWithValue as w, OptimizerInput as wt, concurrency as x, ListReporter as xt, digOriginal as y, InputAtom as yt, ObsidianLanguageCode 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 d9a38e5a..4d5cb3fb 100644 --- a/packages/plugin/dist/index.spec.d.ts +++ b/packages/plugin/dist/index.spec.d.ts @@ -1,2 +1,2 @@ -import { $ as RemoveRecord, A as setNeedMigration, At as FileStat, B as ObsidianLanguageCode, C as digOriginal, Ct as MoveAtom, D as readWithSize, Dt as RootFs, E as pipe, Et as OutputAtom, F as CallableOrObjectTree, Ft as RecordStatsMap, G as On, H as Translate, I as SettingEntry, It as Stat, J as DeciderInput, K as CreateLocalDir, L as AugmentedModuleMeta, Lt as StatsMap, M as generateEditableList, Mt as MaybePromise, N as reactivelyValidate, Nt as Progress, O as writeWithValue, Ot as WrappedFs, P as s, Pt as RecordStat, Q as RemoveRemote, R as ModuleMeta, Rt as Binary, S as SelectFromContext, St as MkdirAtom, T as concurrency, Tt as OptimizerOutput, U as TranslationResource, V as Snippet, W as Dispatch, X as Upload, Y as TaskFactory, Z as ResolveConflict, _ as Context, _t as CustomAtom, a as FsWrapperEntry, at as AddRecord, b as Translations, bt as InputAtom, c as RemoteFsEntry, ct as ConflictResolverPayload, d as RequestParam, dt as DatabaseAsync, et as RemoveLocal, f as RequestResponse, ft as DatabaseSync, g as SyncTerminateReason, gt as BatchOptimizer, h as SyncOptions, ht as StoreSync, i as DeciderEntry, it as CreateRemoteDir, j as LabelDefinition, jt as FolderStat, k as prefixWrapper, kt as WriteAtom, l as RemoteRequestMiddlewareEntry, lt as TaskNames, m as RemoteLister, mt as StoreOperations, n as CheckConnectionResult, nt as MoveLocal, o as LocalRequestMiddlewareEntry, ot as BaseTask, p as TriggerEntry, pt as StoreAsync, q as Decider, r as ConflictResolverEntry, rt as Download, s as OptimizerEntry, st as ConflictResolver, t as VaultRequest, tt as MoveRemote, u as Request, ut as RecordStore, v as Events, vt as DeleteAtom, w as chunkSize, wt as OptimizerInput, x as ExistingMemoryDB, xt as ListReporter, y as Settings, yt as Fs, z as Fragment } from "./index-JdV6c5O1.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 LabelDefinition, 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 RemoteRequestMiddlewareEntry, type RemoveLocal, type RemoveRecord, type RemoveRemote, type Request, type RequestParam, type RequestResponse, type ResolveConflict, type RootFs, SelectFromContext, type SettingEntry, type Settings, type Snippet, type Stat, type StatsMap, type StoreAsync, type StoreOperations, type StoreSync, type SyncOptions, type SyncTerminateReason, type TaskFactory, type TaskNames, type Translate, type TranslationResource, type Translations, type TriggerEntry, type Upload, type VaultRequest, type WrappedFs, type WriteAtom, chunkSize, concurrency, digOriginal, generateEditableList, pipe, prefixWrapper, reactivelyValidate, readWithSize, s, setNeedMigration, writeWithValue }; \ No newline at end of file +import { $ as RemoveLocal, A as s, At as FileStat, B as Snippet, C as readWithSize, Ct as MoveAtom, D as LabelDefinition, Dt as RootFs, E as setNeedMigration, Et as OutputAtom, F as RemoteLister, Ft as RecordStatsMap, G as CreateLocalDir, H as TranslationResource, I as SyncOptions, It as Stat, J as TaskFactory, K as Decider, L as SyncTerminateReason, Lt as StatsMap, M as SettingEntry, Mt as MaybePromise, N as AugmentedModuleMeta, Nt as Progress, O as generateEditableList, Ot as WrappedFs, P as ModuleMeta, Pt as RecordStat, Q as RemoveRecord, R as Fragment, Rt as Binary, S as pipe, St as MkdirAtom, T as prefixWrapper, Tt as OptimizerOutput, U as Dispatch, V as Translate, W as On, X as ResolveConflict, Y as Upload, Z as RemoveRemote, _ as ExistingMemoryDB, _t as DeleteAtom, a as LocalRequestMiddlewareEntry, at as BaseTask, b as chunkSize, bt as ListOptions, c as RemoteRequestMiddlewareEntry, ct as TaskNames, d as RequestResponse, dt as DatabaseSync, et as MoveRemote, f as TriggerEntry, ft as StoreAsync, g as Translations, gt as CustomAtom, h as Settings, ht as BatchOptimizer, i as FsWrapperEntry, it as AddRecord, j as CallableOrObjectTree, jt as FolderStat, k as reactivelyValidate, kt as WriteAtom, l as Request, lt as RecordStore, m as Events, mt as StoreSync, n as ConflictResolverEntry, nt as Download, o as OptimizerEntry, ot as ConflictResolver, p as Context, pt as StoreOperations, q as DeciderInput, r as DeciderEntry, rt as CreateRemoteDir, s as RemoteFsEntry, st as ConflictResolverPayload, t as VaultRequest, tt as MoveLocal, u as RequestParam, ut as DatabaseAsync, v as SelectFromContext, vt as Fs, w as writeWithValue, wt as OptimizerInput, x as concurrency, xt as ListReporter, y as digOriginal, yt as InputAtom, z as ObsidianLanguageCode } from "./index-DbDrKV7S.spec.js"; +export { type AddRecord, type AugmentedModuleMeta, type BaseTask, type BatchOptimizer, type Binary, type CallableOrObjectTree, 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 LabelDefinition, type ListOptions, 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 RemoteRequestMiddlewareEntry, type RemoveLocal, type RemoveRecord, type RemoveRemote, type Request, type RequestParam, type RequestResponse, type ResolveConflict, type RootFs, SelectFromContext, type SettingEntry, type Settings, type Snippet, type Stat, type StatsMap, type StoreAsync, type StoreOperations, type StoreSync, type SyncOptions, type SyncTerminateReason, type TaskFactory, type TaskNames, type Translate, type TranslationResource, type Translations, type TriggerEntry, type Upload, type VaultRequest, type WrappedFs, type WriteAtom, chunkSize, concurrency, digOriginal, generateEditableList, pipe, prefixWrapper, reactivelyValidate, readWithSize, s, setNeedMigration, writeWithValue }; \ No newline at end of file diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 167cb6f3..dc953b99 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@hesprs/sync-engine-sdk", - "version": "3.1.9", + "version": "3.2.0", "description": "Official SDK for developing modules targeting Sync Engine, the extensible Obsidian syncing plugin.", "keywords": [ "obsidian-plugin", diff --git a/packages/plugin/src/components/MigrationModal.ts b/packages/plugin/src/components/MigrationModal.ts index ec937670..5a39fac1 100644 --- a/packages/plugin/src/components/MigrationModal.ts +++ b/packages/plugin/src/components/MigrationModal.ts @@ -1,180 +1,23 @@ -import type { Events } from '@'; import type { App, ToggleComponent } from 'obsidian'; -import { getMessage } from '@repo/shared/error'; -import { Modal, Notice, Setting } from 'obsidian'; -import { ref } from 'synthkernel'; -import type { ExistingMemoryDB } from '@/modules/Bootstrap'; -import type { Dispatch } from '@/modules/EventBus'; -import type { Translate } from '@/modules/I18n'; -import type { Infras } from '@/modules/Registrar'; -import type { SyncTerminateReason } from '@/modules/Sync'; +import { ConfirmationModal } from 'obsidian'; +import type { Fragment, Translate } from '@/modules/I18n'; import type { MaybePromise } from '@/types'; -import renderProgress from '@/components/render-progress'; -import roundPercent from '@/utils/round-percent'; export type MigrationModalTranslations = { cancel: string; + confirm: string; remoteMigration: string; - migrationProcess: string; - startMigration: string; - migrationDescription: string; - migrationPhase1Description: string; - migrationPhase2Description: string; - migrationPhase3Description: string; - toggleWithoutMigration: string; - migrationFailed: string; - completed: string; - hide: string; - done: string; + migrationInstruction: Fragment; }; -type MigrationContext = { - app: App; - dispatch: Dispatch; - translate: Translate; - requestSync: (trigger: string) => Promise; - initializeSync: () => Infras; - memoryDB: ExistingMemoryDB; -}; - -class MigrationModal extends Modal { - private readonly cleanupCallbacks: Array<() => void> = []; - private readonly completed = ref(0); - private readonly current = ref(''); - - constructor( - private readonly ctx: MigrationContext, - private readonly options: { - content: string | DocumentFragment; - apply: () => MaybePromise; - }, - ) { - super(ctx.app); - this.contentEl.addClass('markdown-rendered'); - this.setTitle(ctx.translate('remoteMigration')); - } - - onOpen() { - const { - contentEl, - options: { content, apply }, - } = this; - const { translate } = this.ctx; - contentEl.empty(); - - if (typeof content === 'string') - contentEl.createEl('p', { cls: 'whitespace-pre-wrap', text: content }); - else contentEl.append(content); - contentEl.createEl('p', { - cls: 'whitespace-pre-wrap', - text: translate('migrationDescription'), - }); - - new Setting(contentEl) - .addButton((button) => - button.setButtonText(translate('cancel')).onClick(this.close.bind(this)), - ) - .addButton((button) => - button.setButtonText(translate('toggleWithoutMigration')).onClick(async () => { - await apply(); - this.close(); - }), - ) - .addButton((button) => - button - .setButtonText(translate('startMigration')) - .setCta() - .onClick(this.handleMigration), - ); - } - - private readonly handleMigration = () => { - const { current, completed, ctx, cleanupCallbacks, contentEl, migrate } = this; - const { translate, dispatch } = ctx; - contentEl.empty(); - this.setTitle(translate('migrationProcess')); - const { left, right, bar } = renderProgress(contentEl, 'mb-3'); - - let controls: HTMLElement | undefined; - const renderControls = (text: 'hide' | 'done') => { - controls?.remove(); - controls = new Setting(contentEl).addButton((button) => - button.setButtonText(translate(text)).onClick(() => this.close()), - ).settingEl; - }; - renderControls('hide'); - - cleanupCallbacks.push( - completed.subscribe((value) => { - right.setText(`${value}/3 ${translate('completed')}`); - bar.setValue(roundPercent(value, 3)); - }), - current.subscribe((value) => left.setText(value)), - ); - - void migrate().then((result) => { - renderControls('done'); - if (!result.success) { - dispatch('errorGeneral', 'Migration failed.'); - left.setText(translate('migrationFailed')); - } - }); - }; - - private readonly migrate = async (): Promise< - { success: true } | { success: false; reason: string } - > => { - const { current, completed, ctx } = this; - const { dispatch, requestSync, initializeSync, translate, memoryDB } = ctx; - const handleSyncResult = (sync: SyncTerminateReason, phase: number) => { - if (sync.result === 'failed') - return { reason: `Phase ${phase}: ${sync.error}`, success: false }; - else if (sync.result === 'cancelled') - return { reason: `Phase ${phase}: sync cancelled`, success: false }; - }; - dispatch('logGeneral', 'Migration started.'); - completed(0); - current(translate('migrationPhase1Description')); - - const phase1 = handleSyncResult(await requestSync('nonInteractiveManual'), 1); - if (phase1) return phase1; - completed(1); - current(translate('migrationPhase2Description')); - - try { - const { record, remoteFs } = initializeSync(); - await Promise.all([ - record.clear(), - this.options.apply(), - ...memoryDB - .getStore('remoteContext20000') - .keys() - .sort((a, b) => b.length - a.length) - .map((key) => remoteFs.delete(key)), - ]); - } catch (error) { - const message = getMessage(error); - new Notice(`${translate('migrationFailed')}: ${message}`); - return { reason: `Phase 2: ${message}`, success: false }; - } - completed(2); - current(translate('migrationPhase3Description')); - - const phase3 = handleSyncResult(await requestSync('migration'), 3); - if (phase3) return phase3; - completed(3); - current(translate('completed')); - return { success: true }; - }; - - onClose() { - this.cleanupCallbacks.splice(0).forEach((fn) => fn()); - this.contentEl.empty(); - } -} - export default function setNeedMigration( - ctx: MigrationContext, + { + app, + translate, + }: { + app: App; + translate: Translate; + }, { toggle, needMigration, @@ -197,14 +40,30 @@ export default function setNeedMigration( if (need) { selfTrigger = true; toggle.setValue(!value); // Revert UI back, not migrated yet - new MigrationModal(ctx, { - apply: async () => { - await apply(value); - selfTrigger = true; - toggle.setValue(value); - }, - content: content(value), - }).open(); + const modal = new ConfirmationModal(app) + .setTitle(translate('remoteMigration')) + .setContent( + createFragment((frag) => { + const text = content(value); + frag.append( + text instanceof DocumentFragment ? text : createEl('p', { text }), + translate('migrationInstruction'), + ); + }), + ) + .addCancelButton(translate('cancel')) + .addButton((button) => + button + .setButtonText(translate('confirm')) + .setCta() + .onClick(() => { + selfTrigger = true; + toggle.setValue(value); + return apply(value); + }), + ); + modal.contentEl.addClass('markdown-rendered'); + modal.open(); } else await apply(value); }; const need = needMigration?.(value) ?? true; diff --git a/packages/plugin/src/components/UntrustedModuleModal.ts b/packages/plugin/src/components/UntrustedModuleModal.ts index 1310c591..b7694d50 100644 --- a/packages/plugin/src/components/UntrustedModuleModal.ts +++ b/packages/plugin/src/components/UntrustedModuleModal.ts @@ -28,8 +28,8 @@ export default class UnknownModuleModal extends Modal { }, ) { super(ctx.app); - this.setTitle(ctx.translate('untrustedModule')); - this.contentEl.addClass('markdown-rendered'); + this.setTitle(ctx.translate('untrustedModule')).contentEl.addClass('markdown-rendered'); + if (document.querySelector('body > .mod-dim')) this.setDimBackground(false); } onOpen() { @@ -58,26 +58,28 @@ export default class UnknownModuleModal extends Modal { }); new Setting(this.contentEl) - .addButton((button) => - button.setButtonText(translate('configure')).onClick(async () => { - new ModuleEditorModal(this.ctx, { - file: await app.vault.adapter.read(path), - initial: { id }, - onCancel: () => this.open(), - onSave, - }).open(); - this.close(); - }), - ) .addButton((button) => button .setButtonText(translate('delete')) .setDestructive() - .setCta() .onClick(async () => { await app.vault.adapter.remove(path); this.close(); }), + ) + .addButton((button) => + button + .setButtonText(translate('configure')) + .setCta() + .onClick(async () => { + new ModuleEditorModal(this.ctx, { + file: await app.vault.adapter.read(path), + initial: { id }, + onCancel: () => this.open(), + onSave, + }).open(); + this.close(); + }), ); } diff --git a/packages/plugin/src/components/render-failed-tasks.ts b/packages/plugin/src/components/render-failed-tasks.ts index fd4d50dc..3a020e9f 100644 --- a/packages/plugin/src/components/render-failed-tasks.ts +++ b/packages/plugin/src/components/render-failed-tasks.ts @@ -14,7 +14,7 @@ function renderFailedTaskRow( main.createSpan({ cls: 'text-[--text-muted] whitespace-nowrap', text: prettyName }); main.createSpan({ cls: 'font-semibold truncate', text: key }); - row.createDiv({ cls: 'text-[--text-muted] break-words mt-1', text: error }); + row.createDiv({ cls: 'text-[--text-muted] break-words mt-1', text: error.message }); } export default function renderFailedTasks( diff --git a/packages/plugin/src/en.ts b/packages/plugin/src/en.ts index 3c8d0915..106c7b9b 100644 --- a/packages/plugin/src/en.ts +++ b/packages/plugin/src/en.ts @@ -8,12 +8,11 @@ const pItem = (count: number) => p(count, 'item', 'items'); const pcOperations = (count: number) => pc(count, 'operation', 'operations'); const en: Translations = { - addExclusionRule: 'Add exclusion rule', addHeader: 'Add header', - addInclusionRule: 'Add inclusion rule', addRecord: 'Add record', addSecretHeader: 'Add secret header', addSource: 'Add source', + addStrategy: 'Add strategy', asymmetricStorage: 'Asymmetric storage', asymmetricStorageDescription: () => createFragment((frag) => { @@ -41,7 +40,7 @@ const en: Translations = { text: 'After enabling, please ensure all devices have asymmetric storage enabled.', }); ol.createEl('li', { - text: 'Migration is necessary if this vault was previously uploaded without asymmetric storage.', + text: 'Migration is necessary if this vault was previously synchronized without asymmetric storage.', }); } else { frag.createEl('p', { @@ -55,7 +54,7 @@ const en: Translations = { text: 'Please ensure all devices have asymmetric storage disabled.', }); ol.createEl('li', { - text: 'Migration is necessary if this vault was previously uploaded with asymmetric storage enabled.', + text: 'Migration is necessary if this vault was previously synchronized with asymmetric storage enabled.', }); } }), @@ -65,10 +64,10 @@ const en: Translations = { awaitingConfirmation: 'Awaiting confirmation', backend: 'Storage backend', backendDescription: 'Select the cloud service to use. Backends are provided by modules.', + backendNotInstalled: (name) => `Backend "${name}" not installed!`, bidirectional: 'Bidirectional', cancel: 'Cancel', cancelled: 'Cancelled', - caseSensitive: 'Case sensitive', checkConnection: 'Check connection', checkConnectionFailed: 'Check connection failed', checkConnectionSuccess: 'Check connection succeeded', @@ -104,6 +103,8 @@ const en: Translations = { conflictResolveStrategy: 'Conflict resolve strategy', conflictResolveStrategyDescription: 'Select how to resolve the conflict when both remote and local have been modified since last sync. More strategies can be found in modules.', + conflictResolveStrategyNotInstalled: (strategy) => + `Conflict resolve strategy "${strategy}" not installed!`, controls: 'Controls', createLocalDir: 'Create local folder', createRemoteDir: 'Create remote folder', @@ -119,6 +120,7 @@ const en: Translations = { diffMatchPatch: 'Merge', disableModule: 'Disable module', done: 'Done', + dontSync: "Don't sync", download: 'Download', downloadModule: 'Download module', edit: 'Edit', @@ -126,22 +128,6 @@ const en: Translations = { enable: 'Enable', enableDescription: 'Set whether to load this module.', enableModule: 'Enable module', - exclusionRules: 'Exclusion rules', - exclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - 'Files / folders matching these Glob patterns will not be synced. Please remember to add file extensions (E.g. ', - ); - frag.createEl('code', { text: '.md' }); - frag.appendText(') if you want to exclude files. Refer to '); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: 'settings documentation', - }); - frag.appendText(' for configuration guide.'); - }), executing: 'Executing', export: 'Export', exportLogsDescription: @@ -155,8 +141,7 @@ const en: Translations = { failedToFetchSource: (url) => `Failed to fetch source from "${url}"`, failedToLoadModule: (name) => `Failed to load module "${name}"`, features: 'Features', - filterPlaceholder: 'E.g. temp.md, .trash/**/*', - filterRules: 'Filter rules', + globPlaceholder: 'E.g. temp.md, .trash/**/*', headerKeyPlaceholder: 'Header key', headerValuePlaceholder: 'Header value', hide: 'Hide', @@ -174,20 +159,6 @@ const en: Translations = { }), iconPlaceholder: 'Enter icon code (e.g. puzzle)', idle: 'Idle', - inclusionRules: 'Inclusion rules', - inclusionRulesDescription: () => - createFragment((frag) => { - frag.appendText( - 'Files / folders matching exclusion rules but also matching these glob patterns will still be synced. Refer to ', - ); - frag.createEl('a', { - attr: { - href: 'https://sync.consensia.cc/usage/settings#inclusion-and-exclusion-rules', - }, - text: 'settings documentation', - }); - frag.appendText(' for configuration guide.'); - }), installModuleFromFile: 'Install module from file', installed: 'Installed', integrityVerification: 'Integrity verification', @@ -217,13 +188,18 @@ const en: Translations = { maxRequestConcurrencyDescription: 'Limit the number of simultaneous requests during synchronization. This option is useful for services with request rate limits. Alter the concurrency limit in the field.', maxRequestConcurrencyPlaceholder: 'Enter concurrency limit', - migrationDescription: - 'Migration may take seconds to minutes depending on the vault size. If you have migrated the remote on other devices, you can skip the migration.\n\nStart migration now?', - migrationFailed: 'Migration failed', - migrationPhase1Description: 'Ensure local state is up-to-date', - migrationPhase2Description: 'Clean up remote and records', - migrationPhase3Description: 'Populate remote with new structure', - migrationProcess: 'Migration process', + migrationInstruction: () => + createFragment((frag) => { + const p1 = frag.createEl('p', { text: 'See ' }); + p1.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/remote-migration' }, + text: 'documentation page', + }); + p1.appendText( + ' for how to safely perform a manual migration. For your data safety, Sync Engine does not perform the migration automatically. If you have migrated your cloud storage, you can ignore the warning.', + ); + frag.createEl('p', { text: 'Confirm to toggle?' }); + }), minRequestInterval: 'Min request interval', minRequestIntervalDescription: 'Limit the minimum time between consecutive requests during synchronization. This option is useful for services with request rate limits. Alter the interval in the field.', @@ -257,14 +233,15 @@ const en: Translations = { noInstalledModulesFound: 'No installed modules found.', noMatchingModulesFound: 'No matching modules found.', noModulesAvailable: 'No modules available.', - noRuleConfigured: 'No rule configured.', noSourceConfigured: 'No source configured.', + noStrategyConfigured: 'No strategy 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', openReadme: "Open the module's README page.", + pleaseSetBackend: 'Please set a backend!', readmePage: 'README page', readmePageDescription: 'Set the optional README page of the module, leaving empty means no README.', @@ -317,7 +294,6 @@ const en: Translations = { 'Some modules are hidden since Sync Engine plugin is outdated, update to explore the full module catalog.', speed: 'Speed', speedLabelDescription: 'Properly configuring this setting could improve sync speed.', - startMigration: 'Start migration', startNonInteractiveSync: 'Start non-interactive sync', startSync: 'Start sync', startupSync: 'Startup sync', @@ -327,16 +303,25 @@ const en: Translations = { stopSync: 'Stop sync', syncProgress: 'Sync progress', syncStrategy: 'Sync strategy', - syncStrategyDescription: - 'Select the synchronization strategy to resolve file changes. More strategies can be found in modules.', - toggleWithoutMigration: 'Toggle without migration', + syncStrategyDescription: () => + createFragment((frag) => { + frag.appendText( + 'Configure different synchronization strategies for different files based on Glob rules. The last rule wins when multiple rules match the same file. See ', + ); + frag.createEl('a', { + attr: { href: 'https://sync.consensia.cc/usage/settings#sync-strategy' }, + text: 'documentation page', + }); + frag.append(' for configuration details.'); + }), + syncStrategyNotInstalled: (strategy) => `Sync strategy "${strategy}" not installed!`, untrustedModule: 'Untrusted module', untrustedModuleDescription: ({ fileName, size, path, mtime, ctime }) => createFragment((frag) => { const p1 = frag.createEl('p'); p1.appendText('Sync Engine detected an installed module named '); p1.createEl('code', { text: fileName }); - p1.appendText(', which is never registered in Sync Engine in this vault. '); + p1.appendText(', which is never registered by Sync Engine in this vault. '); p1.createEl('strong', { text: 'Please review following information before proceeding:', }); @@ -361,9 +346,11 @@ const en: Translations = { li5.appendText('Modified at: '); li5.createEl('code', { text: mtime }); const p2 = frag.createEl('p'); - p2.createEl('strong', { text: 'Please avoid enabling modules from unknown sources.' }); + p2.createEl('strong', { + text: 'To prevent malicious code execution, Sync Engine now requires your explicit consent. ', + }); p2.appendText( - 'Directly deleting it if you don\'t know where does it come from; you can choose "Configure" and enable it if it is under your control. For an explanation of this warning, see ', + 'You can choose "Configure" and enable it if it is under your control, or delete it if you don\'t know where is it from. For an explanation of this warning, see ', ); p2.createEl('a', { attr: { href: 'https://sync.consensia.cc/deep-dive/extensibility' }, diff --git a/packages/plugin/src/fs/hierarchical-optimizer.ts b/packages/plugin/src/fs/hierarchical-optimizer.ts index cbff475b..ce3522f8 100644 --- a/packages/plugin/src/fs/hierarchical-optimizer.ts +++ b/packages/plugin/src/fs/hierarchical-optimizer.ts @@ -1,3 +1,4 @@ +import { toError } from '@repo/shared/error'; import { dirname, isSub } from '@repo/shared/path'; import type { DeleteAtom, InputAtom, MoveAtom, OptimizerInput } from './interface'; @@ -103,7 +104,7 @@ export default function hierarchicalOptimizer({ atoms, executeAtom }: OptimizerI ); return await originalExecute(); } catch (error) { - atom.reject(error instanceof Error ? error : new Error(String(error))); + atom.reject(toError(error)); throw error; } }) as never; diff --git a/packages/plugin/src/fs/interface.ts b/packages/plugin/src/fs/interface.ts index 6e6c5c8d..b6301bc1 100644 --- a/packages/plugin/src/fs/interface.ts +++ b/packages/plugin/src/fs/interface.ts @@ -21,9 +21,8 @@ export type RootFs = { list(key: string, reporter: ListReporter): MaybePromise>; // List recursive children under one folder }; -export type ListReporter = ( - progress: Required, -) => MaybePromise<'include' | 'exclude' | 'advance'>; +export type ListOptions = 'include' | 'exclude' | 'advance'; // Include: include this item, don't recurse; exclude: ignore and don't recurse; advance: include and recurse +export type ListReporter = (progress: Required) => MaybePromise; export type WrappedFs = RootFs & { original: Fs }; export type Fs = WrappedFs | RootFs; diff --git a/packages/plugin/src/fs/middlewares/retry.ts b/packages/plugin/src/fs/middlewares/retry.ts index fa1be855..5ba596eb 100644 --- a/packages/plugin/src/fs/middlewares/retry.ts +++ b/packages/plugin/src/fs/middlewares/retry.ts @@ -31,7 +31,6 @@ export default function retryMiddleware(request: Request, options?: RetryOptions }; } -// 401 excluded: needs auth refresh, not retry (e.g. gdrive `bearerMiddleware`). const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]); const RETRYABLE_URL_ERROR_CODES = new Set([-1001, -1003, -1004, -1005, -1006, -1009]); // IOS/macOS native URLSession errors const URL_ERROR_DOMAINS = new Set(['NSURLErrorDomain', 'kCFErrorDomainCFNetwork']); diff --git a/packages/plugin/src/fs/vault/index.ts b/packages/plugin/src/fs/vault/index.ts index 7ce4e5bb..78d5662d 100644 --- a/packages/plugin/src/fs/vault/index.ts +++ b/packages/plugin/src/fs/vault/index.ts @@ -18,7 +18,7 @@ async function getFileUid( ): Promise { const stat = await fs.stat(key); if (stat.isDir) throw new Error(`File "${key}" not found!`); - if (expectedSize !== undefined && stat.size !== expectedSize) return undefined; + if (expectedSize !== undefined && stat.size !== expectedSize) return; return stat.uid; } @@ -87,7 +87,8 @@ export default class VaultFs implements RootFs { return this.request(key, { method: 'DELETE' }); } - move(oldKey: string, newKey: string): Promise { + async move(oldKey: string, newKey: string): Promise { + if (await this.exists(newKey)) await this.delete(newKey); return this.request(oldKey, { destination: newKey, method: 'MOVE' }); } diff --git a/packages/plugin/src/fs/wrappers/asymmetric-storage.ts b/packages/plugin/src/fs/wrappers/asymmetric-storage.ts index 84ac9eb4..a3cefaa2 100644 --- a/packages/plugin/src/fs/wrappers/asymmetric-storage.ts +++ b/packages/plugin/src/fs/wrappers/asymmetric-storage.ts @@ -24,7 +24,7 @@ function joinFileKey(parentKey: string, base: string) { } function parseFlattenedKey(key: string): ParsedFlatKey | undefined { - if (key === ROOT_KEY || key.includes('/')) return undefined; + if (key === ROOT_KEY || key.includes('/')) return; if (key.length > 6 && key[5] === '~') { const base = key.slice(6); if (!base) return; @@ -40,7 +40,6 @@ function parseFlattenedKey(key: string): ParsedFlatKey | undefined { parentAnchor: key.slice(0, 5), }; } - return undefined; } class AsymmetricStorageFs implements WrappedFs { diff --git a/packages/plugin/src/global.css b/packages/plugin/src/global.css index 445755b3..53db10cc 100644 --- a/packages/plugin/src/global.css +++ b/packages/plugin/src/global.css @@ -5,10 +5,6 @@ cursor: not-allowed; } -.background-none { - background: none; -} - .border { border-style: solid; } @@ -51,19 +47,6 @@ input[type='checkbox']:indeterminate { background-color: rgb(246, 5, 34); } -@container (max-width: 500px) { - .sync-engine-togglable-value { - flex-direction: column; - .setting-item-control { - width: 100%; - justify-content: center; - input[type='text'] { - flex: 1; - } - } - } -} - input[type='text'].sync-engine-invalid-input { border-color: var(--text-warning); --background-modifier-border-focus: var(--text-warning); @@ -74,28 +57,11 @@ input[type='text'].sync-engine-invalid-input { padding-right: calc(0.9 * var(--input-height)); } -.sync-engine-setting input[type='text'] { - width: 175px; -} -.is-mobile .sync-engine-setting input[type='text'] { - width: 150px; - flex: 1; -} - .sync-engine-prominent { --flair-background: var(--color-accent); --flair-color: var(--text-on-accent); } -.setting-item.sync-engine-editable-list { - .setting-item-info { - display: none; - } - .setting-item-control input[type='text'] { - flex: 1; - } -} - .sync-engine-cropped-icon { mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath d='M0 0H100V50H75A25 25 0 0 0 50 75V100H0Z' fill='white'/%3E%3C/svg%3E"); } @@ -114,3 +80,53 @@ input[type='text'].sync-engine-invalid-input { } } } + +/* ======== Settings ======== */ +.sync-engine-setting input[type='text'] { + width: 175px; +} +.is-mobile .sync-engine-setting input[type='text'] { + width: 150px; +} + +/* Artificial high specificity to override Obsidian styles */ +div.sync-engine-setting .setting-group .setting-items { + .setting-item { + flex-direction: row; + } + @container (max-width: 500px) { + .setting-item:not(:is(.mod-toggle, .mod-navigable)), + .sync-engine-column { + flex-direction: column; + .setting-item-control { + width: 100%; + > input[type='text'], + > button, + > .dropdown { + max-width: unset; + flex: 1; + } + > button.sync-engine-button-shrink { + flex: 0 1; + } + } + } + } +} + +.setting-group.mod-list .setting-items .setting-item.sync-engine-editable-list { + .setting-item-info { + display: none; + } + .setting-item-control { + width: 100%; + > input[type='text'], + > .dropdown { + flex: 1; + min-width: 0; + } + > button { + width: unset; + } + } +} diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 0394e7f8..7d6bcbf2 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -15,6 +15,7 @@ import Scheduler from '@/modules/Scheduler'; import Setting from '@/modules/Setting'; import Storage from '@/modules/Storage'; import Sync from '@/modules/Sync'; +import { NONE_STRATEGY } from './utils/glob-match'; const internalModules = [ EventBus, @@ -61,29 +62,7 @@ export default class SyncEngine extends Plugin { confirmTasksInSync: true, conflictResolver: 'renameAndKeepBoth', customHeaders: [], - decider: 'bidirectional', - exclusionRules: [ - '.git', - '.github', - '.gitlab', - '.svn', - 'node_modules', - '.DS_Store', - '__MACOSX', - 'desktop.ini', - 'Thumbs.db', - '~$*.doc', - '~$*.docx', - '~$*.ppt', - '~$*.pptx', - '~$*.xls', - '~$*.xlsx', - `${this.app.vault.configDir}/plugins/sync-engine/modules`, - '.trash', - this.app.vault.configDir, - ].map((expr) => ({ caseSensitive: false, expr })), exportLogsDirectory: 'Sync Engine Logs/', - inclusionRules: [], maxFileSize: { enabled: false, value: 31_457_280 }, maxMemoryConsumption: { enabled: true, value: 100 * 1024 ** 2 }, maxRequestConcurrency: { enabled: true, value: 50 }, @@ -97,9 +76,27 @@ export default class SyncEngine extends Plugin { remoteFs: '', scheduledSync: { enabled: false, value: 15 * 60 * 1000 }, startupSync: { enabled: false, value: 5000 }, - ...((await this.loadData()) as Record), + syncStrategy: [ + { expr: '*', strategy: 'bidirectional' }, + ...[ + 'node_modules', + '.DS_Store', + '__MACOSX', + 'desktop.ini', + 'Thumbs.db', + '~$*.{doc,docx,ppt,pptx,xls,xlsx}', + '.git', + '.github', + '.gitlab', + '.trash', + this.app.vault.configDir, + `${this.app.vault.configDir}/plugins/sync-engine/modules`, + ].map((expr) => ({ expr, strategy: NONE_STRATEGY })), + ], + ...((await this.loadData()) as object), }; - void this.saveSettings(); + + migrateSettings(settings, this.saveSettings); // https://github.com/microsoft/TypeScript/issues/62995 const preMerge = { @@ -136,3 +133,24 @@ export default class SyncEngine extends Plugin { readonly saveSettings = () => this.saveData(this.settings); } + +// TODO: remove after November 1, 2026 +type LegacySetting = { + inclusionRules?: Array<{ expr: string }>; + exclusionRules?: Array<{ expr: string }>; + decider?: string; +}; +function migrateSettings(setting: Settings, save: () => Promise) { + const legacy = setting as unknown as LegacySetting; + const { inclusionRules, exclusionRules, decider } = legacy; + if (!inclusionRules || !exclusionRules || !decider) return; + setting.syncStrategy = [ + { expr: '*', strategy: decider }, + ...exclusionRules.map(({ expr }) => ({ expr, strategy: NONE_STRATEGY })), + ...inclusionRules.map(({ expr }) => ({ expr, strategy: decider })), + ]; + delete legacy.exclusionRules; + delete legacy.inclusionRules; + delete legacy.decider; + void save(); +} diff --git a/packages/plugin/src/modules/Bootstrap.ts b/packages/plugin/src/modules/Bootstrap.ts index 90e4bb21..7dc5091a 100644 --- a/packages/plugin/src/modules/Bootstrap.ts +++ b/packages/plugin/src/modules/Bootstrap.ts @@ -8,7 +8,6 @@ import type { BatchOptimizer, Fs, MemoryControlSharedState } from '@/fs'; import type { ControlsSettingTranslations } from '@/settings/controls'; import type { DevelopmentSettingTranslations } from '@/settings/development'; 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'; @@ -89,7 +88,6 @@ export default class Bootstrap { } & ControlsSettingTranslations & DevelopmentSettingTranslations & FeaturesSettingTranslations & - FilterSettingTranslations & HeadSettingTranslations & MiscellaneousSettingTranslations & UntrustedModuleTranslations & @@ -158,6 +156,7 @@ export default class Bootstrap { maxRequestConcurrency.enabled ? maxRequestConcurrency.value : Infinity; const getMinInterval = () => (minRequestInterval.enabled ? minRequestInterval.value : 0); const getDeletionConfirm = () => this.settings.confirmDeleteInAutoSync; + const logger = (log: string) => dispatch('logSync', log); const context20000 = memoryDB.getStore('remoteContext20000'); registerTrigger('realtime', { @@ -194,14 +193,6 @@ export default class Bootstrap { options: () => ({ needConfirmDeletion: getDeletionConfirm() }), priority: 3000, }); - registerTrigger('migration', { - options: () => ({ - decider: mirrorLocalDecider, - detectMoves: false, - remoteLister: () => [], // Remote has already been cleared in phase 2 - }), - priority: 3980, - }); registerTrigger('nonInteractiveManual', { priority: 3990 }); registerTrigger('manual', { options: () => ({ needConfirmTasks: this.settings.confirmTasksInSync }), @@ -364,15 +355,15 @@ export default class Bootstrap { }); registerDecider('bidirectional', { - decider: bidirectionalDecider, + decider: (input) => bidirectionalDecider(input, logger), prettyName: () => t('bidirectional'), }); registerDecider('mirrorLocal', { - decider: mirrorLocalDecider, + decider: (input) => mirrorLocalDecider(input, logger), prettyName: () => t('mirrorLocal'), }); registerDecider('mirrorRemote', { - decider: mirrorRemoteDecider, + decider: (input) => mirrorRemoteDecider(input, logger), prettyName: () => t('mirrorRemote'), }); diff --git a/packages/plugin/src/modules/EventBus.ts b/packages/plugin/src/modules/EventBus.ts index 1bd35ff8..dfe8c056 100644 --- a/packages/plugin/src/modules/EventBus.ts +++ b/packages/plugin/src/modules/EventBus.ts @@ -1,4 +1,5 @@ import type { Events } from '@'; +import { describeError } from '@repo/shared/error'; import { apiVersion, Platform } from 'obsidian'; import { ref } from 'synthkernel'; import type { General } from '@/types'; @@ -40,8 +41,8 @@ export default class EventBus { declare readonly events: { logSync: string; logGeneral: string; - errorSync: string; - errorGeneral: string; + errorSync: Error; + errorGeneral: Error; }; private readonly cleanupCallbacks: Array<() => void> = []; private readonly isIdle = ref(true); @@ -49,8 +50,17 @@ export default class EventBus { private readonly generalLogs: Array = []; constructor() { - const { cleanupCallbacks, on, syncLogs, putSyncLog, putGeneralLog, getThisSync, isIdle } = - this; + const { + cleanupCallbacks, + on, + syncLogs, + putSyncLog, + putGeneralLog, + getThisSync, + isIdle, + putGeneralError, + putSyncError, + } = this; cleanupCallbacks.push( on('syncStarted', ({ trigger }) => { isIdle(false); @@ -58,10 +68,10 @@ export default class EventBus { if (syncLogs.length > MAX_SYNC_LOGS) syncLogs.shift(); putSyncLog(`Sync triggered by \`${trigger}\` started.`); }), - on('logSync', (log) => putSyncLog(log)), - on('errorSync', (log) => putSyncLog(log, 'error')), - on('logGeneral', (log) => putGeneralLog(log)), - on('errorGeneral', (log) => putGeneralLog(log, 'error')), + on('logSync', putSyncLog), + on('errorSync', putSyncError), + on('logGeneral', putGeneralLog), + on('errorGeneral', putGeneralError), on('executionStarted', (tasks) => { getThisSync().totalTasks = tasks.length; putSyncLog(`Execution of ${tasks.length} sync task(s) started.`); @@ -76,10 +86,7 @@ export default class EventBus { const thisSync = getThisSync(); if (thisSync.failedTasks) thisSync.failedTasks += 1; else thisSync.failedTasks = 1; - putSyncLog( - `Task \`${name}\` of \`${key}\` failed with error: \`${error}\`.`, - 'error', - ); + putSyncError(describeError(error, `Task \`${name}\` of \`${key}\` failed`, true)); }), on('tasksConfirmed', (tasks) => putSyncLog(`Confirmed ${tasks.length} task(s).`)), on('syncCanceled', () => putSyncLog('Sync is forced to stop.')), @@ -94,7 +101,7 @@ export default class EventBus { thisSync.outcome = result; thisSync.ended = Date.now(); if (result === 'failed') - putSyncLog(`Sync ended with error: \`${reason.error}\`.`, 'error'); + putSyncError(describeError(reason.error, 'Sync ended with error', true)); else putSyncLog(`Sync ended with result: \`${result}\`.`); isIdle(true); }), @@ -103,15 +110,15 @@ export default class EventBus { ); } - private readonly getThisSync = () => this.syncLogs.at(-1) as SyncStats; - private readonly putSyncLog = (log: string, level: 'info' | 'error' = 'info') => { - const message = `- \`${level.toLocaleUpperCase()}\` - ${log}`; - this.getThisSync().logs.push(message); - }; - private readonly putGeneralLog = (log: string, level: 'info' | 'error' = 'info') => { - const message = `- \`${level.toLocaleUpperCase()}\` - ${log}`; - this.generalLogs.push(`- ${formatDateTime(Date.now(), true)} ${message}`); - }; + private readonly getThisSync = () => this.syncLogs.last() as SyncStats; + private readonly putSyncLog = (log: string) => + this.getThisSync().logs.push(addLevel('INFO', log)); + private readonly putGeneralLog = (log: string) => + this.generalLogs.push(addTime(addLevel('INFO', log))); + private readonly putSyncError = (error: Error) => + this.getThisSync().logs.push(...printError(error)); + private readonly putGeneralError = (error: Error) => + this.generalLogs.push(...printError(error, true)); private readonly subscribers: { [K in keyof Events]?: Set<(event: Events[K]) => void> } = {}; @@ -168,10 +175,11 @@ export default class EventBus { if (outcome) lines.push(`Outcome: \`${outcome}\``); lines.push('Logs:', ''); for (const log of logs) lines.push(log); - lines.push(''); + if (lines.last() !== '') lines.push(''); } if (this.generalLogs.length) - lines.push('---', '', 'General logs:', '', ...this.generalLogs, ''); + lines.push('---', '', 'General logs:', '', ...this.generalLogs); + if (lines.last() !== '') lines.push(''); return lines.join('\n'); }; @@ -188,3 +196,27 @@ export default class EventBus { on: this.on as On, }; } + +const addLevel = (level: string, msg: string) => `- \`${level}\` - ${msg}`; +const addTime = (msg: string) => `- ${formatDateTime(Date.now(), true)} ${msg}`; + +function printError({ cause, message, stack }: Error, time?: boolean): Array { + let causeString: string | undefined; + if (cause) + try { + causeString = JSON.stringify(cause, undefined, 4); + } catch { + // oxlint-disable-next-line typescript/no-base-to-string + causeString = String(cause); + } + const result = [addLevel('ERROR', message)]; + if (time) addTime(result[0]); + if (causeString || stack) { + result[0] += ':'; + result.push('', '```'); + if (stack) result.push(stack); + if (causeString) result.push('Cause:', ` ${causeString}`); + result.push('```', ''); + } else result[0] += '.'; + return result; +} diff --git a/packages/plugin/src/modules/Extensibility.ts b/packages/plugin/src/modules/Extensibility.ts index 368ecdaa..6ab54e80 100644 --- a/packages/plugin/src/modules/Extensibility.ts +++ b/packages/plugin/src/modules/Extensibility.ts @@ -4,7 +4,7 @@ import type { Ref } from 'synthkernel'; import type { DatabaseAsync, StoreAsync, StoreOperations } from 'uni-kv'; import hash from '@repo/shared/crypto'; import { importCode } from '@repo/shared/e2e-utils.spec'; -import { getMessage } from '@repo/shared/error'; +import { describeError, toError } from '@repo/shared/error'; import obsidian, { Notice, requestUrl } from 'obsidian'; import { compare } from 'verkit'; import type { General } from '@/types'; @@ -189,9 +189,9 @@ export default class Extensibility { Object.assign(discoveredMeta, { enabled: false }), ); } - const message = getMessage(error); - dispatch('errorGeneral', `Module \`${id}\` failed to load: ${message}`); - new Notice(`${translate('failedToLoadModule', name)}: ${message}`); + const parsedError = toError(error); + new Notice(`${translate('failedToLoadModule', name)}: ${parsedError.message}`); + dispatch('errorGeneral', describeError(parsedError, `Module \`${id}\` failed to load`)); } }; @@ -234,9 +234,12 @@ export default class Extensibility { } await this.installModule(meta, module); } catch (error) { - const message = getMessage(error); - dispatch('errorGeneral', `Failed to download module \`${id}\`: ${message}`); - new Notice(`${translate('failedToDownloadModule', name)}: ${message}`); + const parsedError = toError(error); + new Notice(`${translate('failedToDownloadModule', name)}: ${parsedError.message}`); + dispatch( + 'errorGeneral', + describeError(parsedError, `Failed to download module \`${id}\``), + ); } if (setBusy) isIdle(true); }; @@ -262,9 +265,13 @@ export default class Extensibility { this.sourceCache.set(url, content); return content as Array; } catch (error) { - const message = getMessage(error); - dispatch('errorGeneral', `Failed to fetch source from \`${url}\`: ${message}`); - if (manual) new Notice(`${translate('failedToFetchSource', url)}: ${message}`); + const parsedError = toError(error); + if (manual) + new Notice(`${translate('failedToFetchSource', url)}: ${parsedError.message}`); + dispatch( + 'errorGeneral', + describeError(parsedError, `Failed to fetch source from \`${url}\``), + ); return []; } }; diff --git a/packages/plugin/src/modules/I18n.ts b/packages/plugin/src/modules/I18n.ts index 82767f66..98091df5 100644 --- a/packages/plugin/src/modules/I18n.ts +++ b/packages/plugin/src/modules/I18n.ts @@ -109,7 +109,7 @@ export default class I18n { private readonly translate = ((key: string, arg: unknown) => { const value = (this.i18n as TranslationResource)[key]; if (typeof value === 'string') return value; - // TODO: 3.1.5 i18n refactor makes all users with any of the legacy i18n modules fail to load. Catch errors to allow the plugin to load. Remove after October 10 + // Handle missing keys and argument mismatch due to stale i18n modules try { return value(arg); } catch { diff --git a/packages/plugin/src/modules/Observability.ts b/packages/plugin/src/modules/Observability.ts index f66d0c1f..dbcc9b40 100644 --- a/packages/plugin/src/modules/Observability.ts +++ b/packages/plugin/src/modules/Observability.ts @@ -1,7 +1,7 @@ import type { Events, Translations } from '@'; import type { App, Command, DataAdapter, IconName } from 'obsidian'; import type { Ref } from 'synthkernel'; -import { getMessage } from '@repo/shared/error'; +import { describeError, toError } from '@repo/shared/error'; import { Notice, Platform, setIcon } from 'obsidian'; import { computed, ref } from 'synthkernel'; import type { Progress } from '@/types'; @@ -171,7 +171,7 @@ export default class Observability { syncStage('completedNoop'); setUpdateInterval(); } else if (result === 'failed') { - this.lastFailure = reason.error; + this.lastFailure = reason.error.message; syncStage('failed'); } walkProgress({ completed: 0, total: 1 }); @@ -298,9 +298,9 @@ export default class Observability { const file = await app.vault.create(filePath, log); await app.workspace.getLeaf().openFile(file); } catch (error) { - const message = getMessage(error); - new Notice(`${translate('exportLogsFailed')}: ${message}`); - dispatch('errorGeneral', `Export log failed: \`${message}\`.`); + const parsedError = toError(error); + new Notice(`${translate('exportLogsFailed')}: ${parsedError.message}`); + dispatch('errorGeneral', describeError(parsedError, 'Export log failed')); } }; diff --git a/packages/plugin/src/modules/Registrar.ts b/packages/plugin/src/modules/Registrar.ts index 48c1d37c..71d4077b 100644 --- a/packages/plugin/src/modules/Registrar.ts +++ b/packages/plugin/src/modules/Registrar.ts @@ -1,12 +1,15 @@ +import type { Events, Translations } from '@'; import type { App, RequestUrlParam } from 'obsidian'; -import type { StoreAsync } from 'uni-kv'; import { toArrayBuffer, toUint8Array } from '@repo/shared/binary'; import hash from '@repo/shared/crypto'; +import { toError } from '@repo/shared/error'; import { requestUrl } from 'obsidian'; import type { BatchOptimizer, Fs, RootFs, VaultRequest } from '@/fs'; -import type { ConflictResolver, Decider } from '@/sync'; -import type { General, MaybePromise, RecordStat, Binary } from '@/types'; +import type { BaseTask, ConflictResolver, Decider, TaskFactory } from '@/sync'; +import type { General, MaybePromise, Binary, StatsMap, RecordStatsMap } from '@/types'; import { createVaultRequest, VaultFs } from '@/fs'; +import type { Dispatch } from './EventBus'; +import type { Snippet, Translate } from './I18n'; import type { RecordStore } from './Storage'; import type { SyncOptions } from './Sync'; @@ -16,11 +19,10 @@ export type RemoteRequestMiddlewareEntry = OrderedWrapperEntry; export type LocalRequestMiddlewareEntry = OrderedWrapperEntry; export type FsWrapperEntry = OrderedWrapperEntry; -export type CheckConnectionResult = { success: true } | { success: false; reason: string }; export type RemoteFsEntry = { instantiate: (request: Request) => RootFs; prettyName: () => string; - checkConnection: (request: Request) => MaybePromise; + checkConnection: (request: Request) => MaybePromise; }; export type DeciderEntry = { decider: Decider; prettyName: () => string }; export type ConflictResolverEntry = { prettyName: () => string; resolver: ConflictResolver }; @@ -64,6 +66,13 @@ const request: Request = async (url: string, params?: RequestParam) => { }; }; +export type DecideTasksInput = { + local: Record; + remote: Record; + record: Record; + taskFactory: TaskFactory; +}; + export default class Registrar { private readonly cleanupCallbacks: Array<() => void> = []; private readonly localFsWrapperRegistry = new Set(); @@ -77,12 +86,20 @@ export default class Registrar { private readonly triggerRegistry = new Map(); private readonly conflictResolverRegistry = new Map(); - declare readonly settings: { remoteFs: string; decider: string; conflictResolver: string }; + declare readonly settings: { remoteFs: string; conflictResolver: string }; + declare readonly i18n: { + pleaseSetBackend: string; + backendNotInstalled: Snippet; + syncStrategyNotInstalled: Snippet; + conflictResolveStrategyNotInstalled: Snippet; + }; constructor( private readonly ctx: { app: App; - getRecordStore: (namespace?: string) => StoreAsync; + getRecordStore: (namespace: string) => RecordStore; + translate: Translate; + dispatch: Dispatch; }, ) {} @@ -97,13 +114,19 @@ export default class Registrar { ); }; - private readonly createRemoteFs = (remoteFs = this.settings.remoteFs) => { + // Returns the instantiated Fs or error + private readonly createRemoteFs = (remoteFs = this.settings.remoteFs): Fs | Error => { const entry = this.remoteFsRegistry.get(remoteFs); if (!entry) { - if (!remoteFs) throw new Error('Please set a backend!'); - throw new Error(`Backend "${remoteFs}" is not installed!`); + const { translate } = this.ctx; + if (!remoteFs) return new Error(translate('pleaseSetBackend')); + return new Error(translate('backendNotInstalled', remoteFs)); + } + try { + return wrapInOrder(entry.instantiate(this.getRequest()), this.remoteFsWrapperRegistry); + } catch (error) { + return toError(error); } - return wrapInOrder(entry.instantiate(this.getRequest()), this.remoteFsWrapperRegistry); }; private readonly getRequest = () => wrapInOrder(request, this.remoteRequestMiddlewareRegistry); @@ -111,16 +134,34 @@ export default class Registrar { private readonly getCheckConnection = (remoteFs = this.settings.remoteFs) => { const entry = this.remoteFsRegistry.get(remoteFs); if (!entry) { - if (!remoteFs) throw new Error('Please install a backend!'); - throw new Error(`Backend "${remoteFs}" is not installed!`); + const { translate } = this.ctx; + if (!remoteFs) throw new Error(translate('pleaseSetBackend')); + throw new Error(translate('backendNotInstalled', remoteFs)); } return () => entry.checkConnection(this.getRequest()); }; - private readonly getDecider = () => { - const decider = this.deciderRegistry.get(this.settings.decider); - if (!decider) throw new Error(`Decider "${this.settings.decider}" not installed!`); - return decider.decider; + private readonly decideTasks = ({ local, record, remote, taskFactory }: DecideTasksInput) => { + const keys = new Set([ + ...Object.keys(local), + ...Object.keys(record), + ...Object.keys(remote), + ]); + const tasks: Array = []; + for (const key of keys) { + const { translate, dispatch } = this.ctx; + const entry = this.deciderRegistry.get(key); + if (!entry) throw new Error(translate('syncStrategyNotInstalled', key)); + const localStats = local[key] ?? new Map(); + const remoteStats = remote[key] ?? new Map(); + const records = record[key] ?? new Map(); + dispatch( + 'logSync', + `Strategy \`${key}\` in scope: local ${localStats.size} item(s), remote ${remoteStats.size} item(s), record ${records.size} item(s).`, + ); + tasks.push(...entry.decider({ localStats, records, remoteStats, taskFactory })); + } + return tasks; }; private readonly optimizeLocal: BatchOptimizer = (input) => @@ -144,19 +185,28 @@ export default class Registrar { private readonly getConflictResolver = () => { const id = this.settings.conflictResolver; const resolver = this.conflictResolverRegistry.get(id); - if (!resolver) throw new Error(`Conflict resolution strategy "${id}" not installed!`); + if (!resolver) + throw new Error(this.ctx.translate('conflictResolveStrategyNotInstalled', id)); return resolver.resolver; }; - private readonly getNamespace = (localFs?: Fs, remoteFs?: Fs) => { + private readonly getNamespace = ( + localFs?: Fs, + remoteFs?: R, + ): R extends Fs ? string : string | Error => { localFs ??= this.createLocalFs(); - remoteFs ??= this.createRemoteFs(); + if (!remoteFs) { + const fs = this.createRemoteFs(); + if (fs instanceof Error) return fs as never; + remoteFs = fs as never; + } return hash(`${localFs.getUid()}~~${remoteFs.getUid()}`); }; private readonly initializeSync = (): Infras => { const localFs = this.createLocalFs(); const remoteFs = this.createRemoteFs(); + if (remoteFs instanceof Error) throw remoteFs; const namespace = this.getNamespace(localFs, remoteFs); const record = this.ctx.getRecordStore(namespace); return { localFs, record, remoteFs }; @@ -166,10 +216,10 @@ export default class Registrar { conflictResolverRegistry: this.conflictResolverRegistry, createLocalFs: this.createLocalFs, createRemoteFs: this.createRemoteFs, + decideTasks: this.decideTasks, deciderRegistry: this.deciderRegistry, getCheckConnection: this.getCheckConnection, getConflictResolver: this.getConflictResolver, - getDecider: this.getDecider, getNamespace: this.getNamespace, getRequest: this.getRequest, getVaultRequest: this.getVaultRequest, diff --git a/packages/plugin/src/modules/Scheduler.ts b/packages/plugin/src/modules/Scheduler.ts index cca353e7..edd064bc 100644 --- a/packages/plugin/src/modules/Scheduler.ts +++ b/packages/plugin/src/modules/Scheduler.ts @@ -1,8 +1,8 @@ import type { Events } from '@'; import type { App, EventRef, TAbstractFile } from 'obsidian'; import type { Ref } from 'synthkernel'; -import type { GlobMatchRule, TogglableValue } from '@/types'; -import { prepareGlobMatch } from '@/utils/glob-match'; +import type { GlobStrategy, TogglableValue } from '@/types'; +import { NONE_STRATEGY, prepareGlobMatch } from '@/utils/glob-match'; import untilTrue from '@/utils/until-true'; import type { Dispatch } from './EventBus'; import type { SyncStage } from './Observability'; @@ -13,6 +13,8 @@ type SyncRequest = { resolve: (result: SyncTerminateReason) => void; }; +const OFFLINE_ERROR = new Error('Device is offline.'); + export default class Scheduler { private readonly pendingRequests: Array = []; private isScheduling = false; @@ -36,8 +38,7 @@ export default class Scheduler { startupSync: TogglableValue; scheduledSync: TogglableValue; realtimeSync: TogglableValue; - exclusionRules: Array; - inclusionRules: Array; + syncStrategy: Array; avoidAutoSyncWhenOffline: boolean; }; @@ -52,7 +53,7 @@ export default class Scheduler { 'logGeneral', `Skipped offline auto sync with trigger \`${trigger}\`.`, ); - return Promise.resolve({ error: 'Device is offline.', result: 'failed' }); + return Promise.resolve({ error: OFFLINE_ERROR, result: 'failed' }); } return new Promise((resolve) => { this.pendingRequests.push({ resolve, trigger }); @@ -110,13 +111,17 @@ export default class Scheduler { } }; - private readonly onChange = (file: TAbstractFile, old?: string) => { + private readonly onChange = ({ path }: TAbstractFile, old?: string) => { if (this.ctx.syncStage() === 'executing') return; - const { realtimeSync, exclusionRules, inclusionRules } = this.settings; + const { realtimeSync, syncStrategy } = this.settings; if (!realtimeSync.enabled) return; - const match = prepareGlobMatch(inclusionRules, exclusionRules); - if (match(file.path) === 'exclude' && !(old && match(old) !== 'exclude')) return; + const match = prepareGlobMatch(syncStrategy); + if ( + match(path).strategy === NONE_STRATEGY && + !(old && match(old).strategy !== NONE_STRATEGY) + ) + return; if (this.realtimeSyncTimer) window.clearTimeout(this.realtimeSyncTimer); this.realtimeSyncTimer = window.setTimeout( diff --git a/packages/plugin/src/modules/Setting.ts b/packages/plugin/src/modules/Setting.ts index 393f30e5..68825cb4 100644 --- a/packages/plugin/src/modules/Setting.ts +++ b/packages/plugin/src/modules/Setting.ts @@ -4,7 +4,6 @@ import { PluginSettingTab } from 'obsidian'; import controlsSettings from '@/settings/controls'; import developmentSettings from '@/settings/development'; import featuresSettings from '@/settings/features'; -import filterSettings from '@/settings/filter'; import headSettings from '@/settings/head'; import miscellaneousSettings from '@/settings/miscellaneous'; import type { On } from './EventBus'; @@ -55,9 +54,8 @@ export default class Setting { }); 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: miscellaneousSettings(this.ctx as Context), priority: 3000 }); + registerSetting({ apply: developmentSettings(this.ctx as Context), priority: 4000 }); }; private readonly matchLabel = () => ({ diff --git a/packages/plugin/src/modules/Storage.ts b/packages/plugin/src/modules/Storage.ts index 1f84b1d7..d9fcafa6 100644 --- a/packages/plugin/src/modules/Storage.ts +++ b/packages/plugin/src/modules/Storage.ts @@ -12,29 +12,27 @@ export default class Storage { private readonly memoryDB = openMemoryDB(STORAGE_NAME); private readonly indexedDB = openIndexedDB(STORAGE_NAME); - constructor(private readonly ctx: { getNamespace: () => string }) {} - - private readonly getRecordStore = (namespace?: string): RecordStore => - this.indexedDB.getStore(namespace || this.ctx.getNamespace()); + constructor(private readonly ctx: { getNamespace: () => string | Error }) {} + + private readonly getRecordStore = ( + namespace?: N, + ): N extends string ? RecordStore : RecordStore | undefined => { + const ns = namespace ?? this.ctx.getNamespace(); + if (ns instanceof Error) return ns as never; + return this.indexedDB.getStore(ns); + }; private readonly deleteRecordStore = (namespace?: string): MaybePromise => { - try { - namespace ??= this.ctx.getNamespace(); - } catch { - return; // When the backend is not set, no need to delete - } - return this.indexedDB.deleteStore(namespace); + const ns = namespace ?? this.ctx.getNamespace(); + if (!(ns instanceof Error)) return this.indexedDB.deleteStore(ns); }; private readonly clearRecordStores = () => this.indexedDB.clearStores(); private readonly recordStoreExists = (namespace?: string): MaybePromise => { - try { - namespace ??= this.ctx.getNamespace(); - } catch { - return false; // When the backend is not set, assume no store - } - return this.indexedDB.getStoreNames().then((names) => names.includes(namespace)); + const ns = namespace ?? this.ctx.getNamespace(); + if (ns instanceof Error) return false; + return this.indexedDB.getStoreNames().then((names) => names.includes(ns)); }; readonly root = { diff --git a/packages/plugin/src/modules/Sync.ts b/packages/plugin/src/modules/Sync.ts index c7d1025e..a91bc047 100644 --- a/packages/plugin/src/modules/Sync.ts +++ b/packages/plugin/src/modules/Sync.ts @@ -1,21 +1,16 @@ import type { Events, Translations } from '@'; import type { Ref } from 'synthkernel'; -import { getMessage } from '@repo/shared/error'; +import { toError } from '@repo/shared/error'; import { isSub } from '@repo/shared/path'; import { ref } from 'synthkernel'; -import type { Fs, ListReporter } from '@/fs'; +import type { Fs, ListOptions, ListReporter } from '@/fs'; +import type { BaseTask, ConflictResolver, TaskFactory, TaskNames, TaskOptionsMap } from '@/sync'; import type { - BaseTask, - ConflictResolver, - Decider, - TaskFactory, - TaskNames, - TaskOptionsMap, -} from '@/sync'; -import type { - GlobMatchRule, + GlobStrategy, MaybePromise, Progress, + RecordStat, + RecordStatsMap, Stat, StatsMap, TogglableValue, @@ -31,30 +26,28 @@ import { syncCancelledError, taskMap, } from '@/sync'; -import { prepareGlobMatch } from '@/utils/glob-match'; +import { NONE_STRATEGY, prepareGlobMatch } from '@/utils/glob-match'; import type { Dispatch, On } from './EventBus'; import type { Translate } from './I18n'; import type { DeleteConfirmReturn } from './ProgressModal'; -import type { Infras } from './Registrar'; +import type { DecideTasksInput, Infras } from './Registrar'; export type SyncTerminateReason = | { result: 'cancelled' } | { result: 'completed' } - | { result: 'failed'; error: string } + | { result: 'failed'; error: Error } | { result: 'noop' }; export type TaskInfo = { name: TaskNames; key: string; prettyName: string; isDir: boolean }; -export type FailedTaskInfo = TaskInfo & { error: string }; +export type FailedTaskInfo = TaskInfo & { error: Error }; export type RemoteLister = (info: Infras & { reporter: ListReporter }) => MaybePromise>; export type SyncOptions = { - decider?: Decider; remoteLister?: RemoteLister; conflictResolver?: ConflictResolver; detectMoves?: boolean; needConfirmTasks?: boolean; needConfirmDeletion?: boolean; - exclusionRules?: Array; - inclusionRules?: Array; + syncStrategy?: Array; }; export default class Sync { @@ -62,10 +55,10 @@ export default class Sync { private readonly ctx: { dispatch: Dispatch; initializeSync: () => Infras; - getDecider: () => Decider; on: On; translate: Translate; getConflictResolver: () => ConflictResolver; + decideTasks: (input: DecideTasksInput) => Array; }, ) {} @@ -81,27 +74,15 @@ export default class Sync { taskFailed: FailedTaskInfo; executionStarted: Array; }; - declare readonly settings: { - maxFileSize: TogglableValue; - exclusionRules: Array; - inclusionRules: Array; - }; + declare readonly settings: { maxFileSize: TogglableValue; syncStrategy: Array }; private readonly postProcess = ( stats: Array, - pruner: (stats: Array) => Array, + organizer: (stats: Array) => Record, ) => { - const statsMap = toMap(pruner(stats)); - const maxSize = this.settings.maxFileSize.enabled - ? this.settings.maxFileSize.value - : Infinity; - const includedStats: StatsMap = new Map(); - if (statsMap.size === 0) return includedStats; - for (const [path, stat] of statsMap) { - if (!stat.isDir && stat.size > maxSize) continue; - includedStats.set(path, stat); - } - return includedStats; + const { enabled, value } = this.settings.maxFileSize; + const maxSize = enabled ? value : Infinity; + return organizer(stats.filter((stat) => stat.isDir || stat.size <= maxSize)); }; private readonly confirmTasks = (tasks: Array) => @@ -146,9 +127,8 @@ export default class Sync { ): Promise => { const { settings, ctx, postProcess, confirmDeletion, confirmTasks, convertDeleteToUpload } = this; - const { on, dispatch, initializeSync, getConflictResolver, translate, getDecider } = ctx; + const { on, dispatch, initializeSync, getConflictResolver, translate, decideTasks } = ctx; const { - decider = getDecider(), remoteLister = async ({ remoteFs, record, reporter }) => { try { return await remoteFs.list('/', reporter); @@ -163,8 +143,7 @@ export default class Sync { detectMoves = true, needConfirmDeletion = false, needConfirmTasks = false, - inclusionRules = settings.inclusionRules, - exclusionRules = settings.exclusionRules, + syncStrategy = settings.syncStrategy, } = options; const isCancelled = ref(false); @@ -177,14 +156,12 @@ export default class Sync { if (isCancelled()) throw syncCancelledError; const infras = initializeSync(); - const { record, localFs } = infras; - - const match = prepareGlobMatch(inclusionRules, exclusionRules); - const { reporter: localReporter, pruner: localPruner } = prepareReporter(match); - const { reporter: remoteReporter, pruner: remotePruner } = prepareReporter(match); + const { record: recordStore, localFs } = infras; + const match = prepareGlobMatch(syncStrategy); + const { reporter: localReporter, organizer: localOrganizer } = prepareList(match); + const { reporter: remoteReporter, organizer: remoteOrganizer } = prepareList(match); dispatch('syncInitialized', { ...infras, match }); - - const [localList, remoteList] = await Promise.all([ + const [localList, remoteList, recordList] = await Promise.all([ localFs.list('/', localReporter), remoteLister({ ...infras, @@ -193,29 +170,20 @@ export default class Sync { return remoteReporter(prog); }, }), + recordStore.entries(), ]); if (isCancelled()) throw syncCancelledError; - const records = new Map(await record.entries()); - const localStats = postProcess(localList, localPruner); - const remoteStats = postProcess(remoteList, remotePruner); - dispatch( - 'logSync', - `Local ${localStats.size} item(s), remote ${remoteStats.size} item(s), record ${records.size} item(s).`, - ); - + const record = organizeRecord(recordList, match); + const local = postProcess(localList, localOrganizer); + const remote = postProcess(remoteList, remoteOrganizer); if (isCancelled()) throw syncCancelledError; + const taskFactory = createTaskFactory({ baseOptions: infras, resolver: conflictResolver, translate, }); - tasks = decider({ - localStats, - logger: (log: string) => dispatch('logSync', log), - records, - remoteStats, - taskFactory, - }); + tasks = decideTasks({ local, record, remote, taskFactory }); if (tasks.length === 0) { terminateReason = { result: 'noop' }; return terminateReason; @@ -223,7 +191,7 @@ export default class Sync { if (detectMoves) { const initialTasks = tasks.length; - tasks = convertMoves(tasks, translate, records); + tasks = convertMoves(tasks, translate, new Map(recordList)); const convertedTasks = initialTasks - tasks.length; if (convertedTasks) dispatch('logSync', `Discovered and converted ${convertedTasks} move task(s).`); @@ -265,10 +233,7 @@ export default class Sync { } catch (error) { if (isCancelled()) return; failedCount++; - dispatch('taskFailed', { - ...toTaskInfo(task), - error: getMessage(error), - }); + dispatch('taskFailed', { ...toTaskInfo(task), error: toError(error) }); } }), ); @@ -277,14 +242,14 @@ export default class Sync { ? { result: 'cancelled' } : failedCount ? { - error: `Execution of ${failedCount} sync task(s) failed.`, + error: new Error(`Execution of ${failedCount} sync task(s) failed.`), result: 'failed', } : { result: 'completed' }; } catch (error) { terminateReason = isCancelled() ? { result: 'cancelled' } - : ({ error: getMessage(error), result: 'failed' } as const); + : ({ error: toError(error), result: 'failed' } as const); } finally { cleanup(); dispatch('syncTerminated', terminateReason); @@ -315,12 +280,6 @@ export default class Sync { root = { executeSync: this.executeSync }; } -function toMap(stats: Array): StatsMap { - const res = new Map(); - for (const stat of stats) res.set(stat.key, stat); - return res; -} - function createTaskFactory({ baseOptions, translate, @@ -373,26 +332,51 @@ function sortTasks(tasks: Array) { }); } -function prepareReporter(match: (path: string) => GlobMatchResult) { - const probes: Array = []; +function prepareList(match: (path: string) => GlobMatchResult) { + const probes = new Set(); + const strategies = new Map(); return { - // Prune probe folders that need to be excluded - pruner: (stats: Array) => { - const probeSet = new Set(probes); - const content = stats.filter((p) => !probeSet.has(p.key)); - if (content.length === 0) return []; + // Prune probe folders that need to be excluded and organize stats into strategies + organizer: (stats: Array) => { + const content = stats.filter(({ key }) => !probes.has(key)); + if (content.length === 0) return {}; const keptProbes = new Set(); - for (const probe of probeSet) - if (content.some((p) => isSub(probe, p.key, false))) keptProbes.add(probe); - return stats.filter((p) => !probeSet.has(p.key) || keptProbes.has(p.key)); + for (const probe of probes) + if (content.some(({ key }) => isSub(probe, key, false))) keptProbes.add(probe); + const pruned = stats.filter(({ key }) => !probes.has(key) || keptProbes.has(key)); + const result: Record = {}; + for (const stat of pruned) { + const { key } = stat; + const strategy = strategies.get(key); + if (!strategy) continue; + result[strategy] ??= new Map(); + result[strategy].set(key, stat); + } + return result; }, - reporter: (prog: Required) => { - const result = match(prog.current); - if (result === 'probe') { - probes.push(prog.current); + reporter: ({ current }: { current: string }): ListOptions => { + const { advance, strategy } = match(current); + if (strategy !== NONE_STRATEGY) strategies.set(current, strategy); + if (advance) { + if (strategy === NONE_STRATEGY) probes.add(current); return 'advance'; } - return result; + if (strategy === NONE_STRATEGY) return 'exclude'; + return 'include'; }, }; } + +function organizeRecord( + records: Array<[string, RecordStat]>, + match: (path: string) => GlobMatchResult, +) { + const result: Record = {}; + for (const [key, stat] of records) { + const { strategy } = match(key); + if (strategy === NONE_STRATEGY) continue; + result[strategy] ??= new Map(); + result[strategy].set(key, stat); + } + return result; +} diff --git a/packages/plugin/src/sdk/index.ts b/packages/plugin/src/sdk/index.ts index 4aa46b6f..ba986afe 100644 --- a/packages/plugin/src/sdk/index.ts +++ b/packages/plugin/src/sdk/index.ts @@ -62,7 +62,6 @@ export type { OptimizerEntry, ConflictResolverEntry, Request, - CheckConnectionResult, RequestParam, RequestResponse, } from '@/modules/Registrar'; diff --git a/packages/plugin/src/settings/development.ts b/packages/plugin/src/settings/development.ts index e293b2b0..02ccbd85 100644 --- a/packages/plugin/src/settings/development.ts +++ b/packages/plugin/src/settings/development.ts @@ -86,7 +86,10 @@ export default function developmentSettings({ }), ) .addButton((button) => { - button.setButtonText(translate('export')).onClick(exportLogs); + button + .setButtonText(translate('export')) + .onClick(exportLogs) + .setClass('sync-engine-button-shrink'); }); }, })), @@ -130,10 +133,7 @@ export default function developmentSettings({ }, text, }); - if (item.new) { - item.new = false; - text.inputEl.focus(); - } + if (item.new) text.inputEl.focus(); }); }, rerenderSettingTab, diff --git a/packages/plugin/src/settings/filter.ts b/packages/plugin/src/settings/filter.ts deleted file mode 100644 index 0e9c3c87..00000000 --- a/packages/plugin/src/settings/filter.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { Settings } from '@'; -import type { SettingGroupItem } from 'obsidian'; -import type { DatabaseSync } from 'uni-kv'; -import type { Fragment, Snippet, Translate } from '@/modules/I18n'; -import type { CallableOrObjectTree } from '@/modules/Setting'; -import type { General, GlobMatchRule } from '@/types'; -import { normalizeGlob } from '@/utils/glob-match'; -import type { LabelDefinition } from './utils'; -import { generateEditableList, reactivelyValidate, s } from './utils'; - -export type FilterSettingTranslations = { - filterRules: string; - inclusionRules: string; - inclusionRulesDescription: Fragment; - exclusionRules: string; - exclusionRulesDescription: Fragment; - xConfigured: Snippet; - addInclusionRule: string; - addExclusionRule: string; - noRuleConfigured: string; - filterPlaceholder: string; - caseSensitive: string; -}; - -export default function filterSettings({ - translate, - saveSettings, - settings, - memoryDB, - rerenderSettingTab, - speedLabel, -}: { - translate: Translate; - saveSettings: () => Promise; - settings: Settings; - memoryDB: DatabaseSync; - rerenderSettingTab: () => void; - speedLabel: () => LabelDefinition; -}): CallableOrObjectTree { - return { - 3000: s( - (self) => ({ - heading: translate('filterRules'), - items: Object.values(self).map((node) => node(node) as SettingGroupItem), - type: 'group', - }), - { - 1000: s( - (self) => ({ - desc: translate('inclusionRulesDescription'), - displayValue: () => - translate('xConfigured', settings.inclusionRules.length), - items: Object.values(self).map((node) => node(node)), - labels: [speedLabel()], - name: translate('inclusionRules'), - type: 'page', - }), - { - 1000: s(() => - generateRuleList({ - add: translate('addInclusionRule'), - empty: translate('noRuleConfigured'), - identifier: 'inclusionRules', - items: settings.inclusionRules, - }), - ), - }, - ), - 2000: s( - (self) => ({ - desc: translate('exclusionRulesDescription'), - displayValue: () => - translate('xConfigured', settings.exclusionRules.length), - items: Object.values(self).map((node) => node(node)), - labels: [speedLabel()], - 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/head.ts b/packages/plugin/src/settings/head.ts index 57576f25..fd598bb7 100644 --- a/packages/plugin/src/settings/head.ts +++ b/packages/plugin/src/settings/head.ts @@ -1,21 +1,17 @@ import type { Context, Events, Settings } from '@'; import type { DatabaseSync } from 'uni-kv'; -import { getMessage } from '@repo/shared/error'; +import { describeError, toError } from '@repo/shared/error'; import { ExtraButtonComponent, Notice, PluginSettingTab, setTooltip } from 'obsidian'; import type { ModuleCtor } from '@/modules/Extensibility'; import type { Fragment, Snippet, Translate } from '@/modules/I18n'; -import type { - CheckConnectionResult, - ConflictResolverEntry, - DeciderEntry, - RemoteFsEntry, -} from '@/modules/Registrar'; +import type { ConflictResolverEntry, DeciderEntry, RemoteFsEntry } from '@/modules/Registrar'; import type { CallableOrObjectTree } from '@/modules/Setting'; import type { Dispatch } from '@/sdk'; -import type { General, MaybePromise } from '@/types'; +import type { General, GlobStrategy, MaybePromise } from '@/types'; +import { normalizeGlob } from '@/utils/glob-match'; import type { AugmentedSettingDefinitionItem, LabelDefinition } from './utils'; import ModuleManagement from './module-management'; -import { s } from './utils'; +import { generateEditableList, reactivelyValidate, s } from './utils'; const CHECK_CONNECTION_INTERVAL = 10_000; @@ -27,14 +23,19 @@ export type HeadSettingTranslations = { backend: string; backendDescription: string; syncStrategy: string; - syncStrategyDescription: string; + syncStrategyDescription: Fragment; + globPlaceholder: string; checkConnectionFailed: string; checkConnectionSuccess: string; checkConnection: string; conflictResolveStrategy: string; conflictResolveStrategyDescription: string; xEnabled: Snippet; + xConfigured: Snippet; settingTips: Fragment<{ labels: Array; addLabel: typeof addLabel }>; + addStrategy: string; + noStrategyConfigured: string; + dontSync: string; }; type CheckConnectionDB = DatabaseSync; @@ -47,12 +48,13 @@ export default function headSettings( remoteFsRegistry: Map; deciderRegistry: Map; conflictResolverRegistry: Map; - getCheckConnection: () => () => MaybePromise; + getCheckConnection: () => () => MaybePromise; memoryDB: CheckConnectionDB; loadedModules: Map; matchLabel: () => LabelDefinition; speedLabel: () => LabelDefinition; dispatch: Dispatch; + rerenderSettingTab: () => void; }, getSettingTab: () => PluginSettingTab | undefined, ): CallableOrObjectTree { @@ -63,6 +65,7 @@ export default function headSettings( settings, remoteFsRegistry, deciderRegistry, + rerenderSettingTab, getCheckConnection, memoryDB, conflictResolverRegistry, @@ -109,7 +112,7 @@ export default function headSettings( .setTooltip(translate('checkConnection')) .onClick(() => void checks.check(true)), getCheckConnection, - log: (str: string) => dispatch('errorGeneral', str), + logError: (error: Error) => dispatch('errorGeneral', error), memoryDB, settings, translate, @@ -140,17 +143,77 @@ export default function headSettings( desc: translate('moduleAutoUpdateDescription'), name: translate('moduleAutoUpdate'), })), - 50: s(() => ({ - control: { - key: 'decider', - options: Object.fromEntries( - [...deciderRegistry].map(([key, { prettyName }]) => [key, prettyName()]), + 50: s( + (self) => ({ + desc: translate('syncStrategyDescription'), + displayValue: () => translate('xConfigured', settings.syncStrategy.length), + items: Object.values(self).map((node) => node(node)), + labels: [speedLabel()], + name: translate('syncStrategy'), + type: 'page', + }), + { + 1000: s(() => + generateEditableList({ + defaultValue: { expr: '', strategy: 'bidirectional' }, + identifier: 'syncStrategy', + items: settings.syncStrategy, + memoryDB, + render: (setting, item, save) => { + setting + .addText((text) => { + text.setPlaceholder(translate('globPlaceholder')).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) text.inputEl.focus(); + }) + .addDropdown((dropdown) => + dropdown + .addOptions({ + ...Object.fromEntries( + [...deciderRegistry].map( + ([key, { prettyName }]) => [key, prettyName()], + ), + ), + none: translate('dontSync'), + }) + .setValue(item.value.strategy) + .onChange((value) => { + item.value.strategy = value; + save(); + }), + ); + }, + reorder: true, + rerenderSettingTab, + saveSettings, + translations: { + add: translate('addStrategy'), + empty: translate('noStrategyConfigured'), + }, + }), ), - type: 'dropdown', }, - desc: translate('syncStrategyDescription'), - name: translate('syncStrategy'), - })), + ), 60: s(() => ({ control: { key: 'conflictResolver', @@ -174,14 +237,14 @@ function setupCheckConnection({ settings, translate, button, - log, + logError, }: { memoryDB: CheckConnectionDB; - getCheckConnection: () => () => MaybePromise; + getCheckConnection: () => () => MaybePromise; settings: Settings; translate: Translate; button: ExtraButtonComponent; - log: (str: string) => void; + logError: (error: Error) => void; }) { let timeout: number | undefined; const possibleClasses = [ @@ -224,24 +287,25 @@ function setupCheckConnection({ setError(); return; } - const onFailure = (message: string) => { + const onFailure = (error: Error) => { setError(); - log(`Check connection to \`${settings.remoteFs}\` failed: \`${message}\`.`); - if (force) new Notice(`${translate('checkConnectionFailed')}: ${message}`, 5000); + if (force) new Notice(`${translate('checkConnectionFailed')}: ${error.message}`, 5000); + logError(describeError(error, `Check connection to \`${settings.remoteFs}\` failed`)); cleanup(); scheduleCheckConnection(); }; try { setChecking(); - const result = await getCheckConnection()(); - if (result.success) { + const failure = await getCheckConnection()(); + if (failure) onFailure(failure); + else { memoryDB.setMeta('lastCheckedFs', settings.remoteFs); setSuccess(); if (force) new Notice(translate('checkConnectionSuccess')); - } else onFailure(result.reason); + } } catch (error) { - onFailure(getMessage(error)); + onFailure(toError(error)); } }; diff --git a/packages/plugin/src/settings/miscellaneous.ts b/packages/plugin/src/settings/miscellaneous.ts index c78f6e84..c35b0857 100644 --- a/packages/plugin/src/settings/miscellaneous.ts +++ b/packages/plugin/src/settings/miscellaneous.ts @@ -109,10 +109,7 @@ export default function miscellaneousSettings({ }, text, }); - if (item.new) { - item.new = false; - text.inputEl.focus(); - } + if (item.new) text.inputEl.focus(); }); if (item.value.type === 'plaintext') setting.addText((text) => diff --git a/packages/plugin/src/settings/utils.ts b/packages/plugin/src/settings/utils.ts index 2cec8005..527c8d24 100644 --- a/packages/plugin/src/settings/utils.ts +++ b/packages/plugin/src/settings/utils.ts @@ -105,7 +105,7 @@ export function renderTogglableValue({ }): (setting: Setting) => void { return (setting) => { setting - .setClass('sync-engine-togglable-value') + .setClass('sync-engine-column') .addText((text) => { text.setPlaceholder(placeholder).setValue(formatType(field.value, type)); reactivelyValidate({ @@ -152,6 +152,7 @@ export function generateEditableList({ render, translations: { add, empty, heading }, extraButtons, + reorder, }: { memoryDB: DatabaseSync; items: Array; @@ -172,18 +173,15 @@ export function generateEditableList({ save: () => void, ) => void >; + reorder?: boolean; }): 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 list: Array> = + ephemeralStore.get(identifier) ?? + 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(); @@ -205,8 +203,9 @@ export function generateEditableList({ name: '', render: (setting) => { setting.settingEl.addClass('sync-engine-editable-list'); - setting.settingEl.querySelector('.setting-item-control')?.addClass('w-100%!'); - return render(setting, item, saveEdit); + const cleanup = render(setting, item, saveEdit); + if (item.new) item.new = false; + return cleanup; }, searchable: false, })), @@ -215,6 +214,14 @@ export function generateEditableList({ saveEdit(); rerenderSettingTab(); }, + onReorder: reorder + ? (oldIndex, newIndex) => { + const [moved] = list.splice(oldIndex, 1); + list.splice(newIndex, 0, moved); + saveEdit(); + rerenderSettingTab(); + } + : undefined, type: 'list', }; } diff --git a/packages/plugin/src/sync/decision/bidirectional.ts b/packages/plugin/src/sync/decision/bidirectional.ts index f2b6e5d7..c2591214 100644 --- a/packages/plugin/src/sync/decision/bidirectional.ts +++ b/packages/plugin/src/sync/decision/bidirectional.ts @@ -3,8 +3,11 @@ import type { BaseTask } from '../tasks/interface'; import type { DeciderInput } from './interface'; import isChanged from '../utils/is-changed'; -export default function bidirectionalDecider(input: DeciderInput): Array { - const { localStats, remoteStats, records, taskFactory, logger } = input; +export default function bidirectionalDecider( + input: DeciderInput, + logger: (log: string) => void, +): Array { + const { localStats, remoteStats, records, taskFactory } = input; const tasks: Array = []; const files: Array<{ diff --git a/packages/plugin/src/sync/decision/interface.ts b/packages/plugin/src/sync/decision/interface.ts index 17fa3265..bf7af841 100644 --- a/packages/plugin/src/sync/decision/interface.ts +++ b/packages/plugin/src/sync/decision/interface.ts @@ -100,5 +100,4 @@ export type DeciderInput = { remoteStats: StatsMap; records: RecordStatsMap; taskFactory: TaskFactory; - logger: (log: string) => void; }; diff --git a/packages/plugin/src/sync/decision/mirror.ts b/packages/plugin/src/sync/decision/mirror.ts index a581cfb6..9edbeed4 100644 --- a/packages/plugin/src/sync/decision/mirror.ts +++ b/packages/plugin/src/sync/decision/mirror.ts @@ -4,16 +4,26 @@ import type { DeciderInput } from './interface'; type Source = 'local' | 'remote'; -export function mirrorLocalDecider(input: DeciderInput): Array { - return mirrorDecider(input, 'local'); +export function mirrorLocalDecider( + input: DeciderInput, + logger: (log: string) => void, +): Array { + return mirrorDecider(input, logger, 'local'); } -export function mirrorRemoteDecider(input: DeciderInput): Array { - return mirrorDecider(input, 'remote'); +export function mirrorRemoteDecider( + input: DeciderInput, + logger: (log: string) => void, +): Array { + return mirrorDecider(input, logger, 'remote'); } -function mirrorDecider(input: DeciderInput, source: Source): Array { - const { localStats, logger, records, remoteStats, taskFactory } = input; +function mirrorDecider( + input: DeciderInput, + logger: (log: string) => void, + source: Source, +): Array { + const { localStats, records, remoteStats, taskFactory } = input; const sourceStats = source === 'local' ? localStats : remoteStats; const targetStats = source === 'local' ? remoteStats : localStats; const tasks: Array = []; diff --git a/packages/plugin/src/sync/utils/detect-moves.ts b/packages/plugin/src/sync/utils/detect-moves.ts index b8167fdf..621c76bf 100644 --- a/packages/plugin/src/sync/utils/detect-moves.ts +++ b/packages/plugin/src/sync/utils/detect-moves.ts @@ -59,7 +59,7 @@ function getCandidate( records?: RecordStatsMap, ): Candidate | undefined { const side = getSide(task); - if (!side) return undefined; + if (!side) return; const expected = isFolder ? isCreate ? side === 'local' @@ -75,11 +75,11 @@ function getCandidate( : side === 'local' ? 'removeLocal' : 'removeRemote'; - if (task.name !== expected) return undefined; + if (task.name !== expected) return; const stat = getTaskStat(task, side, isCreate); - if (!stat || stat.isDir !== isFolder) return undefined; + if (!stat || stat.isDir !== isFolder) return; if (isFolder) return { key: task.key, side, task }; - if (stat.isDir) return undefined; + if (stat.isDir) return; const record = records?.get(task.key); const uid = isCreate ? stat.uid @@ -134,7 +134,6 @@ function getSide(task: BaseTask): MoveSide | undefined { return 'local'; if (task.name === 'upload' || task.name === 'createRemoteDir' || task.name.endsWith('Remote')) return 'remote'; - return undefined; } function getTaskStat(task: BaseTask, side: MoveSide, isCreate: boolean): Stat | undefined { @@ -173,8 +172,7 @@ function replacePair( function getMoveInfo(task: BaseTask): MoveInfo | undefined { const oldKey = (task.options as { oldKey?: unknown }).oldKey; - if (typeof oldKey !== 'string') return undefined; + if (typeof oldKey !== 'string') return; if (task.name === 'moveLocal') return { key: task.key, oldKey, side: 'local' }; if (task.name === 'moveRemote') return { key: task.key, oldKey, side: 'remote' }; - return undefined; } diff --git a/packages/plugin/src/types.ts b/packages/plugin/src/types.ts index cd204be4..845fa669 100644 --- a/packages/plugin/src/types.ts +++ b/packages/plugin/src/types.ts @@ -21,9 +21,9 @@ export type RecordStat = { isDir: false; local: string; remote: string } | { isD export type StatsMap = Map; export type RecordStatsMap = Map; -export type GlobMatchRule = { +export type GlobStrategy = { expr: string; - caseSensitive: boolean; + strategy: string; }; export type Progress = { diff --git a/packages/plugin/src/utils/glob-match.ts b/packages/plugin/src/utils/glob-match.ts index 247400a4..0208deae 100644 --- a/packages/plugin/src/utils/glob-match.ts +++ b/packages/plugin/src/utils/glob-match.ts @@ -1,14 +1,14 @@ import { isFolder } from '@repo/shared/path'; -import type { GlobMatchRule } from '@/types'; +import type { GlobStrategy } from '@/types'; -export type GlobMatchResult = 'include' | 'exclude' | 'advance' | 'probe'; -type SegmentMatcher = RegExp; +export type GlobMatchResult = { strategy: string; advance?: boolean }; +export const NONE_STRATEGY = 'none'; type CompiledRule = { - readonly segments: Array; + readonly strategy: string; + readonly segments: Array; readonly anchored: boolean; readonly directoryOnly: boolean; - readonly hasSlash: boolean; }; type Path = { @@ -29,7 +29,38 @@ function escapeRegExpCharacter(character: string): string { return /[\\()[\]{}|^$.*+?]/u.test(character) ? `\\${character}` : character; } -function compileSegment(pattern: string, flags: string): SegmentMatcher { +function expandBraces(pattern: string): Array { + const open = pattern.indexOf('{'); + if (open === -1) return [pattern]; + const alternatives: Array = []; + let depth = 0; + let end = -1; + let start = open + 1; + for (let index = open; index < pattern.length; index++) { + const character = pattern[index]; + if (character === '{') depth++; + else if (character === '}') { + depth--; + if (depth === 0) { + end = index; + break; + } + } else if (character === ',' && depth === 1) { + alternatives.push(pattern.slice(start, index)); + start = index + 1; + } + } + if (end === -1 || alternatives.length === 0) { + const prefix = pattern.slice(0, open + 1); + return expandBraces(pattern.slice(open + 1)).map((rest) => prefix + rest); + } + const head = pattern.slice(0, open); + const tail = pattern.slice(end + 1); + alternatives.push(pattern.slice(start, end)); + return alternatives.flatMap((alternative) => expandBraces(head + alternative + tail)); +} + +function compileSource(pattern: string): string { let source = ''; for (let index = 0; index < pattern.length; index++) { const character = pattern[index]; @@ -60,7 +91,15 @@ function compileSegment(pattern: string, flags: string): SegmentMatcher { source += `[${negated ? '^' : ''}${negated ? characterClass.slice(1) : characterClass}]`; index = end; } - return new RegExp(`^${source}$`, flags); + return source; +} + +// Each path segment compiles to exactly one regex, so brace alternatives merge into a single alternation; braces expand before compilation, nested groups and later braces expand recursively, while unmatched braces stay literal +function compilePattern(pattern: string): RegExp { + const source = expandBraces(pattern) + .map((alternative) => `(?:${compileSource(alternative)})`) + .join('|'); + return new RegExp(`^${source}$`, 'u'); } export function normalizeGlob(glob: string): string | undefined { @@ -73,93 +112,72 @@ export function normalizeGlob(glob: string): string | undefined { const parts = body.split('/').filter(Boolean); for (const part of parts) try { - compileSegment(part, ''); + compilePattern(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) => - part === '**' && parts.length > 1 ? '**' : compileSegment(part, flags), - ); - +function compileRule({ expr, strategy }: GlobStrategy): CompiledRule { + const leadingSlash = expr.startsWith('/'); + const directoryOnly = expr.endsWith('/'); + const parts = expr.slice(leadingSlash ? 1 : 0, directoryOnly ? -1 : undefined).split('/'); return { - anchored, + // Patterns containing a slash (besides a trailing one) only match from the vault root, while a lone segment matches at any depth + anchored: leadingSlash || parts.length > 1, directoryOnly, - hasSlash: parts.length > 1, - segments, + segments: parts.map((part) => (part === '**' ? '**' : compilePattern(part))), + strategy, }; } -function matchesSegments( - pattern: Array, - path: Array, +function matchSegments( + pattern: Array, + segments: Array, patternIndex = 0, pathIndex = 0, ): boolean { - if (patternIndex === pattern.length) return pathIndex === path.length; + if (patternIndex === pattern.length) return pathIndex === segments.length; const segment = pattern[patternIndex]; if (segment === '**') { - const trailingGlobstar = patternIndex === pattern.length - 1; - if (trailingGlobstar) return pathIndex < path.length || pattern.length === 1; - if (matchesSegments(pattern, path, patternIndex + 1, pathIndex)) return true; + // A trailing globstar consumes one or more segments, an inner one zero or more + if (patternIndex === pattern.length - 1) return pathIndex < segments.length; + if (matchSegments(pattern, segments, patternIndex + 1, pathIndex)) return true; return ( - pathIndex < path.length && matchesSegments(pattern, path, patternIndex, pathIndex + 1) + pathIndex < segments.length && + matchSegments(pattern, segments, patternIndex, pathIndex + 1) ); } return ( - pathIndex < path.length && - segment.test(path[pathIndex]) && - matchesSegments(pattern, path, patternIndex + 1, pathIndex + 1) + pathIndex < segments.length && + segment.test(segments[pathIndex]) && + matchSegments(pattern, segments, patternIndex + 1, pathIndex + 1) ); } -function matchesRule(rule: CompiledRule, path: Path): boolean { +function matchesExactly(rule: CompiledRule, path: Path): boolean { const { segments } = path; - if (!rule.hasSlash) { - const matcher = rule.segments[0]; - if (matcher === '**') return segments.length > 0; - if (rule.anchored) - return ( - segments.length > 0 && - matcher.test(segments[0]) && - (!rule.directoryOnly || segments.length > 1 || path.directory) - ); - - return segments.some( - (segment, index) => - matcher.test(segment) && - (!rule.directoryOnly || index < segments.length - 1 || path.directory), - ); + if (!rule.anchored) { + const [matcher] = rule.segments; + const hit = matcher === '**' ? segments.length > 0 : segments.some((s) => matcher.test(s)); + return hit && (!rule.directoryOnly || path.directory); } - - return matchesSegments(rule.segments, segments) && (!rule.directoryOnly || path.directory); + return matchSegments(rule.segments, segments) && (!rule.directoryOnly || path.directory); } -function matchesAncestor(rule: CompiledRule, path: Path): boolean { - for (let index = 1; index < path.segments.length; index++) - if ( - matchesRule(rule, { - directory: true, - segments: path.segments.slice(0, index), - }) - ) +function matchesRule(rule: CompiledRule, path: Path): boolean { + if (matchesExactly(rule, path)) return true; + // A rule matching an ancestor folder also applies to everything inside it + for (let end = 1; end < path.segments.length; end++) + if (matchesExactly(rule, { directory: true, segments: path.segments.slice(0, end) })) return true; - return false; } -function prefixStates(pattern: Array, path: Array): Set { +function prefixStates(pattern: Array, path: Array): Set { let states = new Set([0]); const close = (input: Set) => { @@ -191,58 +209,61 @@ function prefixStates(pattern: Array, path: Array } function canMatchAnyDescendant(rule: CompiledRule, path: Path): boolean { - if (!rule.hasSlash) { - const matcher = rule.segments[0]; - if (matcher === '**') return true; - if (!rule.anchored || path.segments.length === 0) return true; - return matcher.test(path.segments[0]); - } - - const pending: Array<[number, boolean]> = []; - for (const state of prefixStates(rule.segments, path.segments)) pending.push([state, false]); - const visited = new Set(); - - while (pending.length > 0) { - const [index, consumed] = pending.pop() as [number, boolean]; - const key = `${index}:${consumed}`; - if (visited.has(key)) continue; - visited.add(key); - - if (index === rule.segments.length) { - if (consumed) return true; - continue; - } + if (!rule.anchored) return true; + // Regex segments are always traversable by arbitrary descendant names and a globstar can always consume one segment, so any reachable state before the pattern end absorbs at least one strict descendant segment + return [...prefixStates(rule.segments, path.segments)].some( + (state) => state < rule.segments.length, + ); +} - const segment = rule.segments[index]; - if (segment === '**') pending.push([index + 1, consumed], [index, true]); - else pending.push([index + 1, true]); - } - return false; +// Whether the rule matches every possible strict descendant of the folder: only a trailing run of globstars can absorb arbitrary segments below it, while a fully consumed pattern is a dead end rather than an absorber +function matchesAllDescendants(rule: CompiledRule, path: Path): boolean { + if (rule.directoryOnly) return false; // Files always escape directory-only rules + const { segments: pattern } = rule; + const states = [...prefixStates(pattern, path.segments)]; + return ( + states.some((state) => state < pattern.length && pattern[state] === '**') && + states.every((state) => state === pattern.length || pattern[state] === '**') + ); } -export function prepareGlobMatch( - inclusion: Array = [], - exclusion: Array = [], -): (path: string) => GlobMatchResult { - const inclusions = inclusion.map(compileRule); - const exclusions = exclusion.map(compileRule); +export function prepareGlobMatch(rules: Array): (path: string) => GlobMatchResult { + const compiled = rules.map(compileRule); return (path) => { const parsed = parsePath(path); - if (parsed.segments.length === 0) return 'advance'; - - const included = inclusions.some((rule) => matchesRule(rule, parsed)); - if (included) return parsed.directory ? 'advance' : 'include'; - - const excluded = exclusions.some( - (rule) => matchesRule(rule, parsed) || matchesAncestor(rule, parsed), - ); - if (excluded) { - if (parsed.directory && inclusions.some((rule) => canMatchAnyDescendant(rule, parsed))) - return 'probe'; - return 'exclude'; - } + let strategy = NONE_STRATEGY; + let lastMatch = -1; + for (const [index, rule] of compiled.entries()) + if (matchesRule(rule, parsed)) { + strategy = rule.strategy; + lastMatch = index; + } - return parsed.directory ? 'advance' : 'include'; + if (!parsed.directory) return { strategy }; + + // A later `none` rule matching every possible descendant shadows everything before it, so descendants can only be reclaimed by rules after the highest such catch-all + let floor = lastMatch; + for (const [index, rule] of compiled.entries()) + if ( + index > floor && + rule.strategy === NONE_STRATEGY && + matchesAllDescendants(rule, parsed) + ) + floor = index; + + // Descendants inherit the folder's own strategy unless a catch-all shadows it + const inherits = floor === lastMatch && strategy !== NONE_STRATEGY; + return { + advance: + inherits || + compiled.some( + (rule, index) => + index > floor && + rule.strategy !== NONE_STRATEGY && + canMatchAnyDescendant(rule, parsed), + ), + strategy, + }; }; } diff --git a/packages/plugin/test/bidirectional.test.ts b/packages/plugin/test/bidirectional.test.ts index 9b7613bd..e6558660 100644 --- a/packages/plugin/test/bidirectional.test.ts +++ b/packages/plugin/test/bidirectional.test.ts @@ -1,17 +1,17 @@ import testKit from '$/test-kit'; import { expect, test } from 'bun:test'; +import type { Decider } from '@/sync'; import type { RecordStatsMap, Stat, StatsMap } from '@/types'; import { bidirectionalDecider } from '@/sync'; +const decider: Decider = (input) => bidirectionalDecider(input, () => {}); + const { file, fileRecord, findTask, folder, folderRecord, runDecider, taskKeys, taskNames } = testKit; test('file only local, no record → upload', () => { const local = file('a.md', { uid: 'a-uid' }); - const task = findTask( - runDecider(bidirectionalDecider, { localStats: new Map([['a.md', local]]) }), - 'a.md', - ); + const task = findTask(runDecider(decider, { localStats: new Map([['a.md', local]]) }), 'a.md'); expect(task.name).toBe('upload'); expect(task.local).toBe(local); @@ -20,7 +20,7 @@ test('file only local, no record → upload', () => { test('file only remote, no record → download', () => { const remote = file('a.md', { uid: 'a-uid' }); const task = findTask( - runDecider(bidirectionalDecider, { remoteStats: new Map([['a.md', remote]]) }), + runDecider(decider, { remoteStats: new Map([['a.md', remote]]) }), 'a.md', ); @@ -32,7 +32,7 @@ test('file both sides, no record, same size → addRecord', () => { const local = file('a.md', { uid: 'local-uid' }); const remote = file('a.md', { uid: 'remote-uid' }); const task = findTask( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['a.md', local]]), remoteStats: new Map([['a.md', remote]]), }), @@ -48,7 +48,7 @@ test('file both sides, no record, different size → resolveConflict', () => { const local = file('a.md', { uid: 'local-uid' }); const remote = { ...file('a.md', { uid: 'remote-uid' }), size: 999 }; const task = findTask( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['a.md', local]]), remoteStats: new Map([['a.md', remote]]), }), @@ -64,7 +64,7 @@ test('file with record, both unchanged → no tasks', () => { const records: RecordStatsMap = new Map([['a.md', fileRecord('local-uid', 'remote-uid')]]); expect( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['a.md', local]]), records, remoteStats: new Map([['a.md', remote]]), @@ -77,7 +77,7 @@ test('file with record, both changed → resolveConflict', () => { const remote = file('a.md', { uid: 'new-remote' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('old-local', 'old-remote')]]); const task = findTask( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['a.md', local]]), records, remoteStats: new Map([['a.md', remote]]), @@ -95,7 +95,7 @@ test('file with record, only remote changed → download', () => { const remote = file('a.md', { uid: 'new-remote' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('local-uid', 'old-remote')]]); const task = findTask( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['a.md', local]]), records, remoteStats: new Map([['a.md', remote]]), @@ -112,7 +112,7 @@ test('file with record, only local changed → upload', () => { const remote = file('a.md', { uid: 'remote-uid' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('old-local', 'remote-uid')]]); const task = findTask( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['a.md', local]]), records, remoteStats: new Map([['a.md', remote]]), @@ -128,7 +128,7 @@ test('file with record, no remote, local changed → upload', () => { const local = file('a.md', { uid: 'new-local' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('old-local', 'old-remote')]]); const task = findTask( - runDecider(bidirectionalDecider, { localStats: new Map([['a.md', local]]), records }), + runDecider(decider, { localStats: new Map([['a.md', local]]), records }), 'a.md', ); @@ -140,7 +140,7 @@ test('file with record, no remote, local unchanged → removeLocal', () => { const local = file('a.md', { uid: 'local-uid' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('local-uid', 'old-remote')]]); const task = findTask( - runDecider(bidirectionalDecider, { localStats: new Map([['a.md', local]]), records }), + runDecider(decider, { localStats: new Map([['a.md', local]]), records }), 'a.md', ); @@ -152,7 +152,7 @@ test('file with record, no local, remote changed → download', () => { const remote = file('a.md', { uid: 'new-remote' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('old-local', 'old-remote')]]); const task = findTask( - runDecider(bidirectionalDecider, { records, remoteStats: new Map([['a.md', remote]]) }), + runDecider(decider, { records, remoteStats: new Map([['a.md', remote]]) }), 'a.md', ); @@ -164,7 +164,7 @@ test('file with record, no local, remote unchanged → removeRemote', () => { const remote = file('a.md', { uid: 'remote-uid' }); const records: RecordStatsMap = new Map([['a.md', fileRecord('old-local', 'remote-uid')]]); const task = findTask( - runDecider(bidirectionalDecider, { records, remoteStats: new Map([['a.md', remote]]) }), + runDecider(decider, { records, remoteStats: new Map([['a.md', remote]]) }), 'a.md', ); @@ -175,7 +175,7 @@ test('file with record, no local, remote unchanged → removeRemote', () => { test('folder only local, no record → createRemoteDir', () => { const local = folder('docs/'); const task = findTask( - runDecider(bidirectionalDecider, { localStats: new Map([['docs/', local]]) }), + runDecider(decider, { localStats: new Map([['docs/', local]]) }), 'docs/', ); @@ -186,7 +186,7 @@ test('folder only local, no record → createRemoteDir', () => { test('folder only remote, no record → createLocalDir', () => { const remote = folder('docs/'); const task = findTask( - runDecider(bidirectionalDecider, { remoteStats: new Map([['docs/', remote]]) }), + runDecider(decider, { remoteStats: new Map([['docs/', remote]]) }), 'docs/', ); @@ -197,7 +197,7 @@ test('folder only remote, no record → createLocalDir', () => { test('folder both sides, no record → addRecord', () => { const dir = folder('docs/'); const task = findTask( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['docs/', dir]]), remoteStats: new Map([['docs/', dir]]), }), @@ -211,7 +211,7 @@ test('folder with record, both sides unchanged → no tasks', () => { const dir = folder('docs/'); const rec = folderRecord(); expect( - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['docs/', dir]]), records: new Map([['docs/', rec]]), remoteStats: new Map([['docs/', dir]]), @@ -222,7 +222,7 @@ test('folder with record, both sides unchanged → no tasks', () => { test('folder with record, no remote, content changed → createRemoteDir', () => { // A new subfile has no record entry → folder detected as changed. // The subfile itself also generates an upload task. - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { localStats: new Map([ ['docs/', folder('docs/')], ['docs/note.md', file('docs/note.md', { uid: 'note-uid' })], @@ -237,7 +237,7 @@ test('folder with record, no remote, content changed → createRemoteDir', () => test('folder with record, no remote, content unchanged → removeLocal', () => { // All subfolders have records → isChanged returns false → removeLocal. // Both docs/ and docs/sub/ are unchanged folders with no remote. - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { localStats: new Map([ ['docs/', folder('docs/')], ['docs/sub/', folder('docs/sub/')], @@ -253,7 +253,7 @@ test('folder with record, no remote, content unchanged → removeLocal', () => { }); test('folder with record, no local, remote content changed → createLocalDir', () => { - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { records: new Map([['docs/', folderRecord()]]), remoteStats: new Map([ ['docs/', folder('docs/')], @@ -266,7 +266,7 @@ test('folder with record, no local, remote content changed → createLocalDir', }); test('folder with record, no local, remote content unchanged → removeRemote', () => { - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { records: new Map([ ['docs/', folderRecord()], ['docs/sub/', folderRecord()], @@ -284,7 +284,7 @@ test('folder with record, no local, remote content unchanged → removeRemote', test('file-folder mismatch, both changed → throws', () => { // Record is fileRecord; local=folder, remote=file → both changed (type mismatch) expect(() => - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['item', folder('item')]]), records: new Map([['item', fileRecord('local-uid', 'old-remote')]]), remoteStats: new Map([['item', file('item', { uid: 'remote-uid' })]]), @@ -294,7 +294,7 @@ test('file-folder mismatch, both changed → throws', () => { test('file-folder mismatch, no record → throws', () => { expect(() => - runDecider(bidirectionalDecider, { + runDecider(decider, { localStats: new Map([['item', folder('item')]]), remoteStats: new Map([['item', file('item', { uid: 'remote-uid' })]]), }), @@ -303,7 +303,7 @@ test('file-folder mismatch, no record → throws', () => { test('file-folder: local became dir, remote file unchanged → removeRemote + createRemoteDir', () => { // Record was fileRecord matching remote uid → remote unchanged, local changed - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { localStats: new Map([['item', folder('item')]]), records: new Map([['item', fileRecord('local-uid', 'remote-uid')]]), remoteStats: new Map([['item', file('item', { uid: 'remote-uid' })]]), @@ -314,7 +314,7 @@ test('file-folder: local became dir, remote file unchanged → removeRemote + cr test('file-folder: local became file, remote folder unchanged → removeRemote + upload', () => { // Record was folderRecord → remote folder unchanged, local changed - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { localStats: new Map([['item', file('item', { uid: 'new-local' })]]), records: new Map([['item', folderRecord()]]), remoteStats: new Map([['item', folder('item')]]), @@ -325,7 +325,7 @@ test('file-folder: local became file, remote folder unchanged → removeRemote + test('file-folder: remote became dir, local file unchanged → removeLocal + createLocalDir', () => { // Record was fileRecord matching local uid → local unchanged, remote changed - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { localStats: new Map([['item', file('item', { uid: 'local-uid' })]]), records: new Map([['item', fileRecord('local-uid', 'old-remote')]]), remoteStats: new Map([['item', folder('item')]]), @@ -336,7 +336,7 @@ test('file-folder: remote became dir, local file unchanged → removeLocal + cre test('file-folder: remote became file, local folder unchanged → removeLocal + download', () => { // Record was folderRecord → local folder unchanged, remote changed - const tasks = runDecider(bidirectionalDecider, { + const tasks = runDecider(decider, { localStats: new Map([['item', folder('item')]]), records: new Map([['item', folderRecord()]]), remoteStats: new Map([['item', file('item', { uid: 'new-remote' })]]), @@ -347,7 +347,7 @@ test('file-folder: remote became file, local folder unchanged → removeLocal + test('key only in records (deleted from both sides) → removeRecord', () => { const records: RecordStatsMap = new Map([['gone.md', fileRecord('local-uid', 'remote-uid')]]); - const task = findTask(runDecider(bidirectionalDecider, { records }), 'gone.md'); + const task = findTask(runDecider(decider, { records }), 'gone.md'); expect(task.name).toBe('removeRecord'); }); @@ -360,7 +360,7 @@ test('multiple items produce tasks in file→folder→cleanup order', () => { const remoteStats: StatsMap = new Map([['stale.md', file('stale.md', { uid: 'stale-uid' })]]); const records: RecordStatsMap = new Map([['deleted.md', fileRecord('d-local', 'd-remote')]]); - const tasks = runDecider(bidirectionalDecider, { localStats, records, remoteStats }); + const tasks = runDecider(decider, { localStats, records, remoteStats }); expect(taskNames(tasks)).toStrictEqual([ 'upload', @@ -372,5 +372,5 @@ test('multiple items produce tasks in file→folder→cleanup order', () => { }); test('empty inputs produce no tasks', () => { - expect(runDecider(bidirectionalDecider, {})).toHaveLength(0); + expect(runDecider(decider, {})).toHaveLength(0); }); diff --git a/packages/plugin/test/glob-match.test.ts b/packages/plugin/test/glob-match.test.ts index 349f338d..8fff68c4 100644 --- a/packages/plugin/test/glob-match.test.ts +++ b/packages/plugin/test/glob-match.test.ts @@ -1,12 +1,9 @@ import { expect, test } from 'bun:test'; -import type { GlobMatchRule } from '@/types'; +import type { GlobStrategy } from '@/types'; import type { GlobMatchResult } from '@/utils/glob-match'; -import { normalizeGlob, prepareGlobMatch } from '@/utils/glob-match'; +import { NONE_STRATEGY, normalizeGlob, prepareGlobMatch } from '@/utils/glob-match'; -const rule = (expr: string, caseSensitive = false): GlobMatchRule => ({ - caseSensitive, - expr, -}); +const rule = (expr: string, strategy: string): GlobStrategy => ({ expr, strategy }); const results = (paths: Array, matcher: (path: string) => GlobMatchResult) => Object.fromEntries(paths.map((path) => [path, matcher(path)])); @@ -22,141 +19,235 @@ test('rejects empty and unparseable globs', () => { expect(normalizeGlob(glob)).toBeUndefined(); }); -test('includes files and advances through directories without rules', () => { - const match = prepareGlobMatch(); - expect(results(['/', 'some/file.txt', 'some/'], match)).toEqual({ - '/': 'advance', - 'some/': 'advance', - 'some/file.txt': 'include', +test('defaults paths to the none strategy when no rule matches', () => { + const match = prepareGlobMatch([]); + expect(results(['/', 'some/', 'some/file.txt'], match)).toEqual({ + '/': { advance: false, strategy: NONE_STRATEGY }, + 'some/': { advance: false, strategy: NONE_STRATEGY }, + 'some/file.txt': { strategy: NONE_STRATEGY }, }); }); -test('matches standard wildcards within path segments', () => { - const match = prepareGlobMatch( - [], - [rule('*.log'), rule('debug?.txt'), rule('backup[0-9].sql')], - ); - expect( - results( - ['app.log', 'notes/app.log', 'debug1.txt', 'debug12.txt', 'backup5.sql', 'backupA.sql'], - match, - ), - ).toEqual({ - 'app.log': 'exclude', - 'backup5.sql': 'exclude', - 'backupA.sql': 'include', - 'debug1.txt': 'exclude', - 'debug12.txt': 'include', - 'notes/app.log': 'exclude', +test('applies the last matching rule', () => { + const match = prepareGlobMatch([ + rule('*', 'bidirectional'), + rule('secret.md', 'mirrorLocal'), + rule('secret.md', NONE_STRATEGY), + ]); + expect(results(['note.md', 'secret.md'], match)).toEqual({ + 'note.md': { strategy: 'bidirectional' }, + 'secret.md': { strategy: NONE_STRATEGY }, }); }); -test('matches path separators, root anchoring, and directory suffixes', () => { - const match = prepareGlobMatch([], [rule('doc/*.txt'), rule('/vendor/'), rule('build/')]); +test('matches single-segment patterns at any depth', () => { + const match = prepareGlobMatch([ + rule('*', 'bidirectional'), + rule('*.log', NONE_STRATEGY), + rule('debug?.txt', NONE_STRATEGY), + rule('backup[0-9].sql', NONE_STRATEGY), + ]); expect( results( - ['doc/a.txt', 'doc/deep/a.txt', 'vendor/', 'src/vendor/a.js', 'build/', 'build/app.js'], + [ + 'app.log', + 'notes/app.log', + 'notes/deep/app.log', + 'debug1.txt', + 'debug12.txt', + 'backup5.sql', + 'backupA.sql', + 'keep.txt', + ], match, ), ).toEqual({ - 'build/': 'exclude', - 'build/app.js': 'exclude', - 'doc/a.txt': 'exclude', - 'doc/deep/a.txt': 'include', - 'src/vendor/a.js': 'include', - 'vendor/': 'exclude', + 'app.log': { strategy: NONE_STRATEGY }, + 'backup5.sql': { strategy: NONE_STRATEGY }, + 'backupA.sql': { strategy: 'bidirectional' }, + 'debug1.txt': { strategy: NONE_STRATEGY }, + 'debug12.txt': { strategy: 'bidirectional' }, + 'keep.txt': { strategy: 'bidirectional' }, + 'notes/app.log': { strategy: NONE_STRATEGY }, + 'notes/deep/app.log': { strategy: NONE_STRATEGY }, }); }); -test('includes unmatched files and advances through unmatched directories with inclusions', () => { - const match = prepareGlobMatch([rule('docs/**/*.md')]); +test('anchors slash-containing patterns to the vault root', () => { + const match = prepareGlobMatch([rule('*', 'bidirectional'), rule('doc/*.txt', NONE_STRATEGY)]); + expect(results(['doc/a.txt', 'doc/deep/a.txt', 'other/doc/a.txt'], match)).toEqual({ + 'doc/a.txt': { strategy: NONE_STRATEGY }, + 'doc/deep/a.txt': { strategy: 'bidirectional' }, + 'other/doc/a.txt': { strategy: 'bidirectional' }, + }); +}); + +test('directory suffixes restrict rules to folders and cover their contents', () => { + const match = prepareGlobMatch([ + rule('*', 'bidirectional'), + rule('/vendor/', NONE_STRATEGY), + rule('build/', NONE_STRATEGY), + ]); expect( results( [ - '/', - 'docs/', - 'docs/components/', - 'docs/notes/', - 'docs/readme.md', - 'docs/readme.txt', - 'src/', + 'vendor/', + 'vendor/a.js', + 'src/vendor/', + 'src/vendor/a.js', + 'build/', + 'build/app.js', + 'x/build/f.js', ], match, ), ).toEqual({ - '/': 'advance', - 'docs/': 'advance', - 'docs/components/': 'advance', - 'docs/notes/': 'advance', - 'docs/readme.md': 'include', - 'docs/readme.txt': 'include', - 'src/': 'advance', + 'build/': { advance: false, strategy: NONE_STRATEGY }, + 'build/app.js': { strategy: NONE_STRATEGY }, + 'src/vendor/': { advance: true, strategy: 'bidirectional' }, + 'src/vendor/a.js': { strategy: 'bidirectional' }, + 'vendor/': { advance: false, strategy: NONE_STRATEGY }, + 'vendor/a.js': { strategy: NONE_STRATEGY }, + 'x/build/f.js': { strategy: NONE_STRATEGY }, + }); +}); + +test('probes folders that later rules can reclaim', () => { + const match = prepareGlobMatch([ + rule('*', 'bidirectional'), + rule('build/', NONE_STRATEGY), + rule('build/keep.txt', 'mirrorLocal'), + ]); + expect(results(['build/', 'build/keep.txt', 'build/other.txt'], match)).toEqual({ + 'build/': { advance: true, strategy: NONE_STRATEGY }, + 'build/keep.txt': { strategy: 'mirrorLocal' }, + 'build/other.txt': { strategy: NONE_STRATEGY }, + }); +}); + +test('rules before the last match cannot reclaim pruned folders', () => { + const match = prepareGlobMatch([ + rule('build/keep.txt', 'bidirectional'), + rule('build/', NONE_STRATEGY), + ]); + expect(results(['build/', 'build/keep.txt'], match)).toEqual({ + 'build/': { advance: false, strategy: NONE_STRATEGY }, + 'build/keep.txt': { strategy: NONE_STRATEGY }, }); }); test('matches double-star patterns across directory levels', () => { - const match = prepareGlobMatch( - [], - [rule('**/__pycache__'), rule('assets/**'), rule('foo/**/bar')], - ); + const match = prepareGlobMatch([ + rule('*', 'bidirectional'), + rule('**/__pycache__/', NONE_STRATEGY), + rule('assets/**', NONE_STRATEGY), + rule('a/**/b', NONE_STRATEGY), + ]); expect( results( [ '__pycache__/', + 'src/utils/__pycache__/', 'src/utils/__pycache__/x.py', 'assets/', 'assets/x/y', - 'foo/bar', - 'foo/x/y/bar/z', - 'x/foo/bar', + 'a/b', + 'a/x/y/b', + 'x/a/b', ], match, ), ).toEqual({ - '__pycache__/': 'exclude', - 'assets/': 'advance', - 'assets/x/y': 'exclude', - 'foo/bar': 'exclude', - 'foo/x/y/bar/z': 'exclude', - 'src/utils/__pycache__/x.py': 'exclude', - 'x/foo/bar': 'include', + '__pycache__/': { advance: false, strategy: NONE_STRATEGY }, + 'a/b': { strategy: NONE_STRATEGY }, + 'a/x/y/b': { strategy: NONE_STRATEGY }, + 'assets/': { advance: false, strategy: 'bidirectional' }, + 'assets/x/y': { strategy: NONE_STRATEGY }, + 'src/utils/__pycache__/': { advance: false, strategy: NONE_STRATEGY }, + 'src/utils/__pycache__/x.py': { strategy: NONE_STRATEGY }, + 'x/a/b': { strategy: 'bidirectional' }, }); }); -test('direct inclusion overrides direct and ancestor exclusion', () => { - const match = prepareGlobMatch( - [rule('important.log'), rule('build/keep.txt')], - [rule('*.log'), rule('build/')], - ); +test('prunes subtrees that a later catch-all rule resolves to none', () => { + const match = prepareGlobMatch([rule('a', 'bidirectional'), rule('a/**', 'none')]); + expect(results(['/', 'a/', 'a/deep/', 'a/anything.txt'], match)).toEqual({ + '/': { advance: true, strategy: 'none' }, + 'a/': { advance: false, strategy: 'bidirectional' }, + 'a/anything.txt': { strategy: 'none' }, + 'a/deep/': { advance: false, strategy: 'none' }, + }); +}); + +test('resumes advancing past catch-alls when later rules reclaim descendants', () => { + const match = prepareGlobMatch([ + rule('a', 'bidirectional'), + rule('a/**', 'none'), + rule('a/keep.txt', 'mirrorLocal'), + ]); + expect(results(['a/', 'a/keep.txt', 'a/other.txt'], match)).toEqual({ + 'a/': { advance: true, strategy: 'bidirectional' }, + 'a/keep.txt': { strategy: 'mirrorLocal' }, + 'a/other.txt': { strategy: 'none' }, + }); +}); + +test('prunes the whole vault under a root catch-all', () => { + const match = prepareGlobMatch([rule('*', 'bidirectional'), rule('/**', 'none')]); + expect(results(['/', 'x/', 'x/y'], match)).toEqual({ + '/': { advance: false, strategy: 'none' }, + 'x/': { advance: false, strategy: 'none' }, + 'x/y': { strategy: 'none' }, + }); +}); + +test('advances from the root when later rules can match descendants', () => { + const match = prepareGlobMatch([ + rule('/vendor/', NONE_STRATEGY), + rule('/src/**', 'mirrorLocal'), + ]); + expect(results(['/', 'vendor/', 'src/'], match)).toEqual({ + '/': { advance: true, strategy: NONE_STRATEGY }, + 'src/': { advance: true, strategy: NONE_STRATEGY }, + 'vendor/': { advance: false, strategy: NONE_STRATEGY }, + }); +}); + +test('expands brace alternates within segments', () => { + const match = prepareGlobMatch([ + rule('*', 'bidirectional'), + rule('*.{png,jpg}', NONE_STRATEGY), + rule('{notes,drafts}/*.md', 'mirrorLocal'), + ]); expect( - results( - ['important.log', 'build/', 'build/keep.txt', 'build/keep/more.txt', 'build/other.txt'], - match, - ), + results(['a.png', 'a.jpg', 'a.gif', 'notes/x.md', 'drafts/y.md', 'other/z.md'], match), ).toEqual({ - 'build/': 'probe', - 'build/keep.txt': 'include', - 'build/keep/more.txt': 'exclude', - 'build/other.txt': 'exclude', - 'important.log': 'include', + 'a.gif': { strategy: 'bidirectional' }, + 'a.jpg': { strategy: NONE_STRATEGY }, + 'a.png': { strategy: NONE_STRATEGY }, + 'drafts/y.md': { strategy: 'mirrorLocal' }, + 'notes/x.md': { strategy: 'mirrorLocal' }, + 'other/z.md': { strategy: 'bidirectional' }, }); }); -test('probes excluded directories that may contain included descendants', () => { - const match = prepareGlobMatch([rule('build/keep.txt')], [rule('build/')]); - expect(results(['build/', 'build/keep.txt', 'build/other.txt'], match)).toEqual({ - 'build/': 'probe', - 'build/keep.txt': 'include', - 'build/other.txt': 'exclude', +test('expands nested braces and keeps unmatched braces literal', () => { + const match = prepareGlobMatch([rule('a{b{1,2},c}', NONE_STRATEGY)]); + expect(results(['ab1', 'ab2', 'ac', 'a{b1', 'x'], match)).toEqual({ + ab1: { strategy: NONE_STRATEGY }, + ab2: { strategy: NONE_STRATEGY }, + ac: { strategy: NONE_STRATEGY }, + 'a{b1': { strategy: NONE_STRATEGY }, + x: { strategy: NONE_STRATEGY }, }); }); -test('honors case sensitivity per rule', () => { - const match = prepareGlobMatch([], [rule('README.md'), rule('Secret.txt', true)]); - expect(results(['readme.md', 'README.md', 'secret.txt', 'Secret.txt'], match)).toEqual({ - 'README.md': 'exclude', - 'Secret.txt': 'exclude', - 'readme.md': 'exclude', - 'secret.txt': 'include', +test('probes when only files can escape a directory-only shadow', () => { + const match = prepareGlobMatch([rule('a/*', 'mirrorLocal'), rule('a/*/', NONE_STRATEGY)]); + expect(results(['a/', 'a/x', 'a/x/', 'a/x/y'], match)).toEqual({ + 'a/': { advance: true, strategy: NONE_STRATEGY }, + 'a/x': { strategy: 'mirrorLocal' }, + 'a/x/': { advance: false, strategy: NONE_STRATEGY }, + 'a/x/y': { strategy: NONE_STRATEGY }, }); }); diff --git a/packages/plugin/test/mirror.test.ts b/packages/plugin/test/mirror.test.ts index ac490a3f..1a76620f 100644 --- a/packages/plugin/test/mirror.test.ts +++ b/packages/plugin/test/mirror.test.ts @@ -6,8 +6,8 @@ import { mirrorLocalDecider, mirrorRemoteDecider } from '@/sync'; const { file, fileRecord, findTask, folder, folderRecord, runDecider, taskNames } = testKit; const strategies: Array<[string, Decider, 'local' | 'remote']> = [ - ['mirror local', mirrorLocalDecider, 'local'], - ['mirror remote', mirrorRemoteDecider, 'remote'], + ['mirror local', (input) => mirrorLocalDecider(input, () => {}), 'local'], + ['mirror remote', (input) => mirrorRemoteDecider(input, () => {}), 'remote'], ]; for (const [name, decider, source] of strategies) { diff --git a/packages/plugin/test/test-kit.ts b/packages/plugin/test/test-kit.ts index 03c389f8..3c186a9d 100644 --- a/packages/plugin/test/test-kit.ts +++ b/packages/plugin/test/test-kit.ts @@ -104,7 +104,7 @@ function runDecider( return task; }) as TaskFactory; - decider({ localStats, logger: () => {}, records, remoteStats, taskFactory }); + decider({ localStats, records, remoteStats, taskFactory }); return tasks; } diff --git a/packages/s3/src/index.ts b/packages/s3/src/index.ts index b0f616aa..e74588ba 100644 --- a/packages/s3/src/index.ts +++ b/packages/s3/src/index.ts @@ -63,7 +63,7 @@ export default class S3 { registerRemoteOptimizer: (entry: OptimizerEntry) => () => void; registerRemoteRequestMiddleware: (entry: RemoteRequestMiddlewareEntry) => () => void; memoryDB: S3DB; - getRecordStore: (namespace?: string) => RecordStore; // TODO: remove after October 13 + getRecordStore: (namespace?: string) => RecordStore | Error; // TODO: remove after October 13 }>, ) { ctx.registerI18n('en', en); @@ -166,7 +166,9 @@ export default class S3 { }), ); - if (this.settings.remoteFs === 's3') void migrateEtag(getRecordStore()).catch(() => {}); + const store = getRecordStore(); + if (this.settings.remoteFs === 's3' && !(store instanceof Error)) + void migrateEtag(store).catch(() => {}); }; private readonly resolveConfig = () => { diff --git a/packages/s3/src/optimizer.ts b/packages/s3/src/optimizer.ts index 3efd5884..89d370b3 100644 --- a/packages/s3/src/optimizer.ts +++ b/packages/s3/src/optimizer.ts @@ -1,40 +1,33 @@ import type { DeleteAtom, OptimizerInput, OptimizerOutput } from '@hesprs/sync-engine-sdk'; import { digOriginal } from '@hesprs/sync-engine-sdk'; -import S3Fs, { BATCH_DELETE_MAX_KEYS } from './s3/fs'; +import { toError } from '@repo/shared/error'; +import S3Fs from './s3/fs'; export default function s3BatchDeleteOptimizer({ atoms, fs, }: OptimizerInput): OptimizerOutput | undefined { const original = digOriginal(fs); - if (!(original instanceof S3Fs)) return undefined; - const s3Fs = original; + if (!(original instanceof S3Fs)) return; const deleteAtoms = atoms.filter((a): a is DeleteAtom => a.type === 'delete'); if (deleteAtoms.length <= 1) return atoms; const otherAtoms = atoms.filter((a) => a.type !== 'delete'); - const batchGroups: Array> = []; - for (let i = 0; i < deleteAtoms.length; i += BATCH_DELETE_MAX_KEYS) - batchGroups.push(deleteAtoms.slice(i, i + BATCH_DELETE_MAX_KEYS)); - const batchAtoms = batchGroups.map((batch) => ({ + const batchAtom = { execute: async () => { - const keys = batch.map((a) => a.key); + const keys = deleteAtoms.map((a) => a.key); try { - const result = await s3Fs.batchDelete(keys); - batch.forEach((atom) => { + const result = await original.batchDelete(keys); + deleteAtoms.forEach((atom) => { const status = result[atom.key]; - if (status === true) atom.resolve(); - else - atom.reject( - new Error(status ?? `S3 batch delete missing result for ${atom.key}.`), - ); + if (status) atom.reject(status); + else atom.resolve(); }); } catch (error) { - const reason = - error instanceof Error ? error : new Error(String(error), { cause: error }); - batch.forEach((atom) => atom.reject(reason)); + const reason = toError(error); + deleteAtoms.forEach((atom) => atom.reject(reason)); } }, type: 'custom' as const, - })); - return [...otherAtoms, ...batchAtoms]; + }; + return [...otherAtoms, batchAtom]; } diff --git a/packages/s3/src/s3/check-connection.ts b/packages/s3/src/s3/check-connection.ts index 9bc7b044..78e13e78 100644 --- a/packages/s3/src/s3/check-connection.ts +++ b/packages/s3/src/s3/check-connection.ts @@ -1,5 +1,5 @@ -import type { CheckConnectionResult, Request } from '@hesprs/sync-engine-sdk'; -import { getMessage } from '@repo/shared/error'; +import type { Request } from '@hesprs/sync-engine-sdk'; +import { toError } from '@repo/shared/error'; import type { UrlStyle } from './sigv4'; import { buildUrlWithQuery } from './url'; import { parseS3Error } from './utils'; @@ -14,7 +14,7 @@ export type S3ConnectionOptions = { export async function checkConnection( options: S3ConnectionOptions, request: Request, -): Promise { +): Promise { try { const url = buildUrlWithQuery( { @@ -26,12 +26,9 @@ export async function checkConnection( { 'list-type': '2', 'max-keys': '0' }, ); const response = await request(url, { method: 'GET', throw: false }); - if (response.status >= 200 && response.status < 300) return { success: true } as const; - return { - reason: parseS3Error(response.text()) ?? `S3: HTTP ${response.status}`, - success: false, - } as const; + if (response.status >= 200 && response.status < 300) return; + return new Error(parseS3Error(response.text()) ?? `HTTP ${response.status}`); } catch (error) { - return { reason: getMessage(error), success: false } as const; + return toError(error); } } diff --git a/packages/s3/src/s3/fs.ts b/packages/s3/src/s3/fs.ts index 2b320a8f..c082a721 100644 --- a/packages/s3/src/s3/fs.ts +++ b/packages/s3/src/s3/fs.ts @@ -10,7 +10,7 @@ import type { } from '@hesprs/sync-engine-sdk'; import { chunkSize, concurrency } from '@hesprs/sync-engine-sdk'; import { concatBinary, textToUint8Array } from '@repo/shared/binary'; -import { getMessage, getStatus } from '@repo/shared/error'; +import { getStatus, toError } from '@repo/shared/error'; import normalizeEtag from '@repo/shared/normalize-etag'; import parseXML from '@repo/shared/parse-xml'; import { dirname, encodeUrl, isFolder } from '@repo/shared/path'; @@ -30,7 +30,7 @@ export type S3FsOptions = { request: Request; }; -export const BATCH_DELETE_MAX_KEYS = 1000; +const BATCH_DELETE_MAX_KEYS = 1000; type S3ListBucketResult = { ListBucketResult: { @@ -80,15 +80,12 @@ function asArray(value: T | Array | undefined): Array { return value === undefined ? [] : Array.isArray(value) ? value : [value]; } -function parseBatchDeleteResponse(xml: string, keys: Array): Record { - const result = Object.fromEntries(keys.map((key) => [key, true])) as Record< - string, - true | string - >; - if (!xml.trim()) return result; +function parseBatchDelete(xml: string): Record { + if (!xml.trim()) return {}; + const result: Record = {}; const errors = asArray(parseXML(xml).DeleteResult?.Error); - for (const error of errors) - if (error.Key && error.Code) result[error.Key] = formatS3Error(error.Code, error.Message); + for (const { Key, Code, Message } of errors) + if (Key && Code) result[Key] = new Error(formatS3Error(Code, Message)); return result; } @@ -203,40 +200,46 @@ export default class S3Fs implements RootFs { * Batch delete — S3-specific extension method accessed by the optimizer. * Up to 1000 keys per DeleteObjects request. */ - async batchDelete(keys: Array): Promise> { - const result: Record = {}; - for (let i = 0; i < keys.length; i += BATCH_DELETE_MAX_KEYS) { - const batch = keys.slice(i, i + BATCH_DELETE_MAX_KEYS); - const body = buildDeleteObjectsXml(batch); - const url = buildUrlWithQuery( - { bucket: this.bucket, endpoint: this.endpoint, key: '/', urlStyle: this.urlStyle }, - { delete: '' }, - ); - let response: RequestResponse; - try { - response = await this.requestOrThrow(url, { - body: textToUint8Array(body), - headers: { - 'Content-MD5': await md5Base64(body), - 'Content-Type': 'application/xml', + async batchDelete(keys: Array): Promise> { + const result: Record = {}; + const batches: Array> = []; + for (let i = 0; i < keys.length; i += BATCH_DELETE_MAX_KEYS) + batches.push(keys.slice(i, i + BATCH_DELETE_MAX_KEYS)); + await Promise.all( + batches.map(async (batch) => { + const body = buildDeleteObjectsXml(batch); + const url = buildUrlWithQuery( + { + bucket: this.bucket, + endpoint: this.endpoint, + key: '/', + urlStyle: this.urlStyle, }, - method: 'POST', - }); - } catch { - await Promise.all( - batch.map(async (key) => { - try { - await this.delete(key); - result[key] = true; - } catch (error) { - result[key] = getMessage(error); - } - }), + { delete: '' }, ); - continue; - } - Object.assign(result, parseBatchDeleteResponse(response.text(), batch)); - } + let response: RequestResponse; + try { + response = await this.requestOrThrow(url, { + body: textToUint8Array(body), + headers: { + 'Content-MD5': await md5Base64(body), + 'Content-Type': 'application/xml', + }, + method: 'POST', + }); + } catch { + await Promise.all( + batch.map(async (key) => + this.delete(key).catch( + (error: unknown) => (result[key] = toError(error)), + ), + ), + ); + return; + } + Object.assign(result, parseBatchDelete(response.text())); + }), + ); return result; } diff --git a/packages/s3/src/s3/sigv4.ts b/packages/s3/src/s3/sigv4.ts index 7d908334..5173b43b 100644 --- a/packages/s3/src/s3/sigv4.ts +++ b/packages/s3/src/s3/sigv4.ts @@ -150,6 +150,8 @@ export async function signRequest( // Strips host from actually sent headers to prevent Electron throwing delete headers.host; + // Electron stamps `Sec-Fetch-Mode: no-cors` on every request. Backblaze B2 rejects authenticated POSTs carrying it (multipart upload initiation) as an attempt to bypass CORS from a browser fetch. Overriding with `navigate` keeps Chromium from CORS-enforcing the response (unlike `cors`) and stops the request from looking like a browser fetch, while staying an unsigned header per SigV4. + headers['Sec-Fetch-Mode'] = 'navigate'; return headers; } diff --git a/packages/s3/src/setting.ts b/packages/s3/src/setting.ts index 32f5fac6..7fa66cc3 100644 --- a/packages/s3/src/setting.ts +++ b/packages/s3/src/setting.ts @@ -150,6 +150,7 @@ export default function s3Setting( name: translate('sessionToken'), render: (setting) => { setting + .setClass('sync-engine-column') .addComponent((element) => new SecretComponent(app, element) .setValue(settings.sessionToken.value) @@ -219,6 +220,7 @@ export default function s3Setting( name: translate('proxyUrl'), render: (setting) => { setting + .setClass('sync-engine-column') .addText((text) => { text.setPlaceholder(translate('proxyUrlPlaceholder')).setValue( settings.proxyUrl.value, diff --git a/packages/s3/test/check-connection.test.ts b/packages/s3/test/check-connection.test.ts index 1e8bea4b..21cb47ad 100644 --- a/packages/s3/test/check-connection.test.ts +++ b/packages/s3/test/check-connection.test.ts @@ -22,7 +22,7 @@ test('checkConnection uses the request pipeline for a signed empty list request' const harness = testKit.request(() => response()); const request = sigv4Middleware(harness.request, defaultCredentials, memoryDB); - expect(await checkConnection(connectionOptions, request)).toStrictEqual({ success: true }); + expect(await checkConnection(connectionOptions, request)).toBeUndefined(); const call = harness.calls[0]; if (!call) throw new Error('Expected checkConnection request'); @@ -41,25 +41,22 @@ test('checkConnection surfaces the S3 error body on failure', async () => { const denied = testKit.request(() => response({ status: 403, text: '...' }), ).request; - expect(await checkConnection(connectionOptions, denied)).toStrictEqual({ - reason: 'S3 AccessDenied: Access Denied', - success: false, - }); + const deniedError = await checkConnection(connectionOptions, denied); + expect(deniedError).toBeInstanceOf(Error); + expect((deniedError as Error).message).toBe('S3 AccessDenied: Access Denied'); }); test('checkConnection returns HTTP and thrown request failures', async () => { const failed = testKit.request(() => response({ status: 403 })).request; - expect(await checkConnection(connectionOptions, failed)).toStrictEqual({ - reason: 'S3: HTTP 403', - success: false, - }); + const httpError = await checkConnection(connectionOptions, failed); + expect(httpError).toBeInstanceOf(Error); + expect((httpError as Error).message).toBe('HTTP 403'); const requestError = new Error('network unavailable'); const thrown = testKit.request(() => { throw requestError; }).request; - expect(await checkConnection(connectionOptions, thrown)).toStrictEqual({ - reason: 'network unavailable', - success: false, - }); + const thrownError = await checkConnection(connectionOptions, thrown); + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe('network unavailable'); }); diff --git a/packages/s3/test/fs-s3.test.ts b/packages/s3/test/fs-s3.test.ts index 3b11d0d7..73f06d33 100644 --- a/packages/s3/test/fs-s3.test.ts +++ b/packages/s3/test/fs-s3.test.ts @@ -236,8 +236,8 @@ test('batchDelete escapes keys, sends MD5 XML, and batches at 1000 keys', async expect(bodies[0]).toContain('a<&"''); expect((bodies[0]?.match(//gu) ?? []).length).toBe(1000); expect((bodies[1]?.match(//gu) ?? []).length).toBe(1); - expect(result['a<&"\'']).toBe('S3 AccessDenied: no permission'); - expect(result['key-0']).toBe(true); + expect(result['a<&"\'']?.message).toBe('S3 AccessDenied: no permission'); + expect(result['key-0']).toBeUndefined(); }); test('batch delete rejects only atoms with S3 partial failures', async () => { @@ -298,8 +298,8 @@ test('batchDelete retries each key individually when the batch request fails', a return response({ status: 204 }); }); const result = await s3.fs.batchDelete(['ok.md', 'broken.md']); - expect(result['ok.md']).toBe(true); - expect(result['broken.md']).toBe('S3 InternalError: boom'); + expect(result['ok.md']).toBeUndefined(); + expect(result['broken.md']?.message).toBe('S3 InternalError: boom'); expect(methods).toStrictEqual(['POST', 'DELETE', 'DELETE']); }); diff --git a/packages/s3/test/sigv4-middleware.test.ts b/packages/s3/test/sigv4-middleware.test.ts index 513983a9..5dcdcd96 100644 --- a/packages/s3/test/sigv4-middleware.test.ts +++ b/packages/s3/test/sigv4-middleware.test.ts @@ -94,6 +94,18 @@ test('middleware signs custom headers before proxy rewrites the URL', async () = ); }); +test("middleware overrides Electron's no-cors fetch mode as an unsigned header", async () => { + const { calls, transport } = createTransport(); + const request = sigv4Middleware(transport, defaultCredentials, memoryDB); + + await request('https://s3.example.com/vault/file.bin', { method: 'POST' }); + + const call = calls[0]; + if (!call) throw new Error('Expected transport request'); + expect(call.headers?.['Sec-Fetch-Mode']).toBe('navigate'); + expect(call.headers?.authorization).not.toContain('sec-fetch-mode'); +}); + test('middleware reuses signing key for matching credentials and date', async () => { const { transport } = createTransport(); const request = sigv4Middleware(transport, defaultCredentials, memoryDB); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 032ad8e2..ebaf2e50 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -19,6 +19,12 @@ export function getStatus(error: unknown): number | undefined { for (const candidate of candidates) if (typeof candidate === 'number') return candidate; } -export function getMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +export function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error), { cause: error }); +} + +export function describeError(error: Error, msg: string, newObj?: boolean) { + const e = newObj ? structuredClone(error) : error; + e.message = `${msg}: \`${error.message}\``; + return e; } diff --git a/packages/shared/src/obsidian-mock.spec.ts b/packages/shared/src/obsidian-mock.spec.ts index 43d16459..fbc06dc1 100644 --- a/packages/shared/src/obsidian-mock.spec.ts +++ b/packages/shared/src/obsidian-mock.spec.ts @@ -33,6 +33,7 @@ export class TextComponent {} export class ButtonComponent {} export class ProgressBarComponent {} export class SecretComponent {} +export class ConfirmationModal {} export function setIcon() {} export function setTooltip() {} diff --git a/packages/smart-merge/src/diff3/index.ts b/packages/smart-merge/src/diff3/index.ts index 31592367..d3055ea9 100644 --- a/packages/smart-merge/src/diff3/index.ts +++ b/packages/smart-merge/src/diff3/index.ts @@ -227,9 +227,7 @@ export function diffMerge( o: ReadonlyArray, b: ReadonlyArray, ): Array> { - // Applies the output of diff3MergeIndices to actually - // Construct the merged file; the returned result alternates - // Between "ok" and "conflict" blocks. + // Applies the output of diff3MergeIndices to actually construct the merged file; the returned result alternates between "ok" and "conflict" blocks. const result: Array> = []; const indices = diff3MergeIndices(a, o, b); diff --git a/packages/smart-merge/src/index.ts b/packages/smart-merge/src/index.ts index 81a88258..a292de6d 100644 --- a/packages/smart-merge/src/index.ts +++ b/packages/smart-merge/src/index.ts @@ -2,7 +2,7 @@ import type { ConflictResolverEntry, DatabaseAsync, ObsidianLanguageCode, - Fs, + Context, FsWrapperEntry, SelectFromContext, SettingEntry, @@ -31,7 +31,7 @@ export default class SmartMerge { indexedDB: SmartMergeDatabase; translate: Translate; saveSettings: () => Promise; - getNamespace: (localFs?: Fs, remoteFs?: Fs) => string; + getNamespace: Context['getNamespace']; registerI18n: (locale: ObsidianLanguageCode, resource: TranslationResource) => void; registerRemoteFsWrapper: (entry: FsWrapperEntry) => () => void; registerConflictResolver: (id: string, entry: ConflictResolverEntry) => () => void; @@ -78,7 +78,7 @@ export default class SmartMerge { }), registerSetting({ apply: smartMergeSetting({ saveSettings, translate }, this.moduleSettings), - priority: 4048, + priority: 3048, }), ); }; diff --git a/packages/smart-merge/src/setting.ts b/packages/smart-merge/src/setting.ts index 6cb4b546..2cee9155 100644 --- a/packages/smart-merge/src/setting.ts +++ b/packages/smart-merge/src/setting.ts @@ -35,7 +35,6 @@ export default function smartMergeSetting( name: translate('conflictOursMarkers'), render: (setting) => { setting - .setClass('sync-engine-togglable-value') .addText(marker('conflictAStart', translate('start'))) .addText(marker('conflictAEnd', translate('end'))); }, @@ -45,7 +44,6 @@ export default function smartMergeSetting( name: translate('conflictTheirsMarkers'), render: (setting) => { setting - .setClass('sync-engine-togglable-value') .addText(marker('conflictBStart', translate('start'))) .addText(marker('conflictBEnd', translate('end'))); }, @@ -55,7 +53,6 @@ export default function smartMergeSetting( name: translate('deletionMarkers'), render: (setting) => { setting - .setClass('sync-engine-togglable-value') .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 6c6c4d65..84437718 100644 --- a/packages/webdav/src/index.ts +++ b/packages/webdav/src/index.ts @@ -42,7 +42,7 @@ export default class Webdav { registerRemoteFsWrapper: (entry: FsWrapperEntry) => () => void; registerSetting: (entry: SettingEntry) => () => void; registerI18n: (lang: ObsidianLanguageCode, translations: TranslationResource) => void; - getRecordStore: (namespace?: string) => RecordStore; // TODO: remove after October 13 + getRecordStore: (namespace?: string) => RecordStore | Error; // TODO: remove after October 13 }>, ) { if (!this.moduleSettings.baseDirectory) @@ -107,7 +107,9 @@ export default class Webdav { }), ); - if (this.settings.remoteFs === 'webdav') void migrateEtag(getRecordStore()).catch(() => {}); + const store = getRecordStore(); + if (this.settings.remoteFs === 'webdav' && !(store instanceof Error)) + void migrateEtag(store).catch(() => {}); }; readonly dispose = () => { diff --git a/packages/webdav/src/webdav/check-connection.ts b/packages/webdav/src/webdav/check-connection.ts index b9e06f79..39ac10e4 100644 --- a/packages/webdav/src/webdav/check-connection.ts +++ b/packages/webdav/src/webdav/check-connection.ts @@ -1,5 +1,5 @@ -import type { CheckConnectionResult, Request } from '@hesprs/sync-engine-sdk'; -import { getMessage } from '@repo/shared/error'; +import type { Request } from '@hesprs/sync-engine-sdk'; +import { toError } from '@repo/shared/error'; import { normalizeUrl } from '@repo/shared/path'; import { buildUrl, getAuthorization, parseWebDAVError } from './utils'; @@ -17,7 +17,7 @@ const CHECK_CONNECTION_BODY = ` export async function checkConnection( { username, password, endpoint }: WebdavConnectionOptions, request: Request, -): Promise { +): Promise { const Authorization = getAuthorization(username, password); try { const response = await request(buildUrl(normalizeUrl(endpoint), '/'), { @@ -27,12 +27,9 @@ export async function checkConnection( method: 'PROPFIND', throw: false, }); - if (response.status === 200 || response.status === 207) return { success: true } as const; - return { - reason: parseWebDAVError(response.text()) ?? response.status.toString(), - success: false, - } as const; + if (response.status === 200 || response.status === 207) return; + return new Error(parseWebDAVError(response.text()) ?? `HTTP ${response.status}`); } catch (error) { - return { reason: getMessage(error), success: false } as const; + return toError(error); } } diff --git a/packages/webdav/test/fs-webdav.test.ts b/packages/webdav/test/fs-webdav.test.ts index 84388777..0690ba2d 100644 --- a/packages/webdav/test/fs-webdav.test.ts +++ b/packages/webdav/test/fs-webdav.test.ts @@ -99,7 +99,7 @@ async function collectStream(source: ReadableStream): Promise { test('checkConnection returns success for a healthy endpoint', async () => { const harness = request(() => defaultResponse); - expect(await checkConnection(defaultOptions, harness.request)).toStrictEqual({ success: true }); + expect(await checkConnection(defaultOptions, harness.request)).toBeUndefined(); expect(harness.calls[0]).toMatchObject({ method: 'PROPFIND', url: 'https://dav.example.com/dav/', @@ -109,10 +109,9 @@ test('checkConnection returns success for a healthy endpoint', async () => { test('checkConnection returns failure reason for bad status', async () => { const harness = request(() => ({ status: 503, text: () => '' })); - expect(await checkConnection(defaultOptions, harness.request)).toStrictEqual({ - reason: '503', - success: false, - }); + const failure = await checkConnection(defaultOptions, harness.request); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toBe('HTTP 503'); }); test('stat parses dav fields and prefers etag for uid', async () => { diff --git a/versions.json b/versions.json index 95d9633f..42a7187f 100644 --- a/versions.json +++ b/versions.json @@ -15,5 +15,6 @@ "3.1.6": "1.13.0", "3.1.7": "1.13.0", "3.1.8": "1.13.0", - "3.1.9": "1.13.0" + "3.1.9": "1.13.0", + "3.2.0": "1.13.0" }