Compare commits

...
Author SHA1 Message Date
vorotamoroz c011eeda77 Merge origin/main into optional-file sync ownership 2026-09-04 10:34:41 +00:00
vorotamoroz 60b093612b Extract optional-file synchronisation workflow owners 2026-09-04 09:33:36 +00:00
vorotamoroz 045a328697 Merge pull request #1162 from vrtmrz/refactor/conflict-resolution-service-features
Refactor conflict resolution into service features
2026-09-04 18:31:00 +09:00
vorotamoroz b6b9ce3ba1 Document conflict dialogue lifecycle fixes 2026-09-04 08:30:42 +00:00
vorotamoroz f06f33cbf4 Strengthen conflict resolution regression coverage 2026-09-04 08:16:14 +00:00
vorotamoroz 355d819f43 Extract optional-file sync context capabilities 2026-09-04 06:23:54 +00:00
vorotamoroz 56a1a19d2c Merge latest main into conflict resolution refactor 2026-09-04 05:35:39 +00:00
vorotamoroz 8449f9d3f5 Refactor optional file synchronisation ownership 2026-09-04 05:19:47 +00:00
vorotamoroz 84be444689 Simplify conflict scheduling and lifecycle subscriptions 2026-09-04 05:15:45 +00:00
vorotamoroz 54d276f5e5 Merge pull request #1161 from vrtmrz/refactor/startup-lifecycle-service-features
Refactor startup lifecycle into service features
2026-09-04 11:37:47 +09:00
vorotamoroz f70bdbbbe0 refactor: clarify startup operation defaults 2026-09-04 02:30:40 +00:00
vorotamoroz 22a835519b fix: preserve startup UI behaviour 2026-09-04 01:55:29 +00:00
vorotamoroz ad91776ad9 Test conflict dialogue concurrency and unload lifecycle 2026-09-04 01:47:29 +00:00
vorotamoroz 826413bf84 Refactor optional file synchronisation ownership 2026-09-03 15:43:21 +00:00
vorotamoroz 6ea906b575 Refactor conflict resolution into service features 2026-09-03 12:35:58 +00:00
vorotamoroz 338aecd888 Refactor startup lifecycle into service features 2026-09-03 12:20:05 +00:00
vorotamoroz 3b2d5aa5af Merge pull request #1160 from vrtmrz/docs/current-data-structure-reference
Correct the database structure reference for 1.0
2026-09-03 16:39:46 +09:00
vorotamoroz f055222160 Document current database structure boundary 2026-09-03 07:23:15 +00:00
vorotamoroz 77882c677b Merge pull request #1159 from vrtmrz/docs/replicator-architecture
Document the implemented Replicator architecture
2026-09-03 15:57:31 +09:00
vorotamoroz 2461d37ead Document the implemented Replicator architecture
- mark capability and lifecycle ADRs as accepted
- add lifecycle, fencing, and provider-extension guidance
- split project terminology into a dedicated glossary
2026-09-03 06:42:34 +00:00
vorotamoroz d00c5ecc56 Merge pull request #1158 from vrtmrz/1_0_24
Releasing 1.0.24
2026-09-03 14:47:22 +09:00
vorotamoroz 7b4f7bf514 Prepare release 1.0.24 2026-09-03 04:17:05 +00:00
vorotamoroz ba8f910865 Merge pull request #1157 from vrtmrz/fix/setup-wizard-synchronising-device-copy
Align existing-device setup guidance with synchronising-device terminology
2026-09-03 12:17:26 +09:00
vorotamoroz c58e462057 Align existing-device setup guidance terminology 2026-09-03 03:01:05 +00:00
vorotamoroz a09c59aaba Merge pull request #1118 from nikhilmaddirala/agent/fix-setup-wizard-existing-device-copy
Fix existing-device setup guidance
2026-09-03 00:38:57 +09:00
vorotamoroz cbbae33d67 Merge pull request #1130 from zeedif/feat/setup-wizard-ux
feat(setup-wizard): improve E2EE dialog UX and password field
2026-09-02 23:54:25 +09:00
vorotamoroz be9c328f4a fix(setup-wizard): enlarge mobile password toggle 2026-09-02 14:47:21 +00:00
vorotamoroz a9b146a0ab test(setup-wizard): enforce mobile password touch target 2026-09-02 14:47:21 +00:00
vorotamoroz 5d5e448c6b Merge pull request #1129 from zeedif/fix/es-locale-and-onboarding-ux
fix(i18n): Spanish catalogue placeholders and missing strings
2026-09-02 23:27:39 +09:00
vorotamoroz 725db213c3 Merge pull request #1150 from vrtmrz/fix/1142-cli-systemd-installer
Fix systemd CLI installation and start-up checks
2026-09-02 21:43:49 +09:00
vorotamoroz a2441b9870 Integrate merged E2EE rebuild fix
# Conflicts:
#	updates.md
2026-09-02 12:34:58 +00:00
vorotamoroz 01c38268c7 Merge pull request #1149 from vrtmrz/fix/1146-preserve-e2ee-on-rebuild
Preserve device E2EE settings during remote rebuild
2026-09-02 21:32:54 +09:00
vorotamoroz 64e17ab920 Clarify systemd CLI installation guidance 2026-09-02 12:20:50 +00:00
vorotamoroz d8fccd6e4b Integrate systemd CLI installer fix with current main 2026-09-02 11:43:59 +00:00
vorotamoroz 50ad4c4bdf Integrate E2EE rebuild preservation with current main 2026-09-02 11:30:20 +00:00
vorotamoroz 8cb5d6d87f Adopt Replicator capability and lifecycle orchestration (#1154)
Merge the reviewed Replicator ownership and capability refactor after exact-release validation, stable promotion, and successful BRAT testing.
2026-09-02 16:59:20 +09:00
vorotamoroz 79eb940d80 Release Self-hosted LiveSync 1.0.23
Merge the exact reviewed release commit after published-artefact and BRAT validation.
2026-09-02 16:23:18 +09:00
vorotamoroz 4b47ebbd4d fix(cli): install complete systemd runtime 2026-08-30 09:38:34 +00:00
vorotamoroz b28871ab67 Preserve device E2EE settings during remote rebuild 2026-08-30 07:43:20 +00:00
Zeedif 2c35f45765 fix(setup-wizard): address review — icon, CSS scope, E2E coverage
- Use a plain emoji glyph for the password visibility toggle instead
  of embedded SVG paths, matching the same pragmatic approach already
  used for the browser build's menu icons in this codebase.
- Scope the wider label width to the E2EE dialogue instead of
  changing it for every InputRow in every Svelte dialogue.
- Extend the existing CouchDB manual setup E2E workflow to assert the
  passphrase and Obfuscate Properties fields are hidden until
  encryption is enabled, then appear, and that the passphrase value
  survives toggling its visibility.
2026-08-26 20:45:44 -06:00
Zeedif caa8c92cbe fix(i18n): keep activateReason default untranslated per review
Commonlib compares this value against the raw "updated" literal to
distinguish automatic activation from a user-requested run, so
translating it changes behaviour rather than just the displayed text.
Keep the default raw and drop the "updated" catalogue entry; the
manually-requested reason stays translated.
2026-08-26 20:41:00 -06:00
ZeedifandClaude Sonnet 5 2cefff43bb fix(i18n): repair Spanish catalogue placeholders and missing strings
- Fix %{Display language} placeholder mismatch so the language-switch
  notice resolves to the actual language name instead of leaving the
  raw token in the Spanish text.
- Translate the remaining English strings shown in the Setup Wizard
  Intro and CouchDB screens (missing setup/CouchDB copy, "Check server
  requirements", "Create/Connect to database and continue", etc.).
- Translate the Config Doctor's activation reason (previously spliced
  into the Spanish sentence as the raw English word "updated" or
  "you wanted(Thank you)!") by routing it through the catalogue at
  the two call sites that set it.
- Fix Spanish strings that kept English-style Title Case instead of
  sentence case, and route the "OK" button through the catalogue so it
  renders as "Aceptar".

Scope is limited to Spanish content plus the minimal code changes
needed to make two hard-coded strings translatable at all; no other
locale files were touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:33:57 -06:00
ZeedifandClaude Sonnet 5 6b37ea8778 feat(setup-wizard): improve E2EE dialog UX and password field
- Only show the passphrase and "Obfuscate Properties" controls once
  end-to-end encryption is enabled, instead of leaving them visible
  but disabled.
- Replace the password show/hide checkbox with an icon toggle button.
- Let translated labels wrap to their content instead of being
  clipped into a fixed-width column (this clipped longer translated
  labels, e.g. in German and Spanish).
- Scope the onboarding invitation link's 44px touch-target padding to
  mobile, so on desktop it renders as a normal inline link instead of
  a stray, oddly-padded button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:23:50 -06:00
nikhilmaddirala 01c060558a Fix existing-device setup guidance 2026-08-18 10:27:05 -04:00
213 changed files with 23760 additions and 7643 deletions
+5 -4
View File
@@ -5,10 +5,11 @@ When working on this repository (writing code, comments, documentation, or commi
## Required Reference Files
Before making changes to documentation, user-facing text, or settings:
1. Read [docs/terms.md](docs/terms.md) for terminology, vocabulary conventions, and technical definitions.
2. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
3. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
4. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
1. Read [docs/terms.md](docs/terms.md) for documentation style and vocabulary conventions.
2. Read [docs/glossary.md](docs/glossary.md) for user-facing, operational, developer, and design terminology.
3. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
4. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
5. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
---
+4 -1
View File
@@ -57,7 +57,10 @@ To maintain consistency across the project, we ask that you follow the establish
- **Affirmative Phrasing**: Avoid asking questions using negative forms in user-facing dialogue. Use affirmative questions to prevent translation and interpretation discrepancies.
- **Specific Words**: Use 'dialogue' for documentation and user-facing messages (use 'dialog' only inside source code). Use the hyphenated form 'plug-in' in user-facing text (use 'plugin' only in configuration settings or technical contexts).
For a detailed list of vocabulary conventions and terms, please refer to [docs/terms.md](docs/terms.md).
For writing conventions, see [Documentation style and vocabulary conventions](docs/terms.md).
Project-specific meanings are defined in the [Project glossary](docs/glossary.md),
including internal developer and design terms which might not appear in the
user interface.
### 3. Translations
+16 -5
View File
@@ -85,7 +85,7 @@ To facilitate development and testing, the build process can automatically copy
Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current Replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
- **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper.
@@ -129,6 +129,10 @@ Changes spanning both repositories must first produce a packed Commonlib artefac
## Architecture
The [Project glossary](docs/glossary.md#developer-and-design-terms) defines the
stable developer and design vocabulary used in this section. The guidance
below describes how those boundaries are applied.
### Service composition and legacy Modules
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
@@ -138,7 +142,7 @@ The application is composed from Services, ServiceModules, serviceFeatures, add-
- **serviceFeature**: a typed composition function which accepts only its declared Services and ServiceModules. It registers lifecycle handlers, commands, user-interface bindings, or other host glue, and may return a focused view. It is not a runtime registry entry.
- **AbstractModule** and **AbstractObsidianModule**: the legacy application Module layer. Existing Modules are loaded by the application and bound after the Service graph has been composed; this broad core access is not the preferred dependency boundary for new orchestration.
The normal composition order is the Service Hub, replicator-provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
The normal composition order is the Service Hub, Replicator provider registration, ServiceModules, serviceFeatures, add-ons, and finally legacy Module binding. A serviceFeature may therefore consume an already constructed ServiceModule. Preferring a serviceFeature for new composition is a dependency-boundary rule, not an initialisation-order rule.
Mutable state is permitted in a serviceFeature. State alone is not a reason to create a class, a ServiceModule, or retain an AbstractModule. Prefer one private context, with module-level functions which receive that context, when identity and polymorphism are not part of the contract. Separate the state, transitions, and invariants from the surrounding function which registers lifecycle handlers and connects downstream effects. Give the stateful boundary narrow collaborators rather than `LiveSyncBaseCore`.
@@ -154,6 +158,8 @@ Use interaction-based, London School unit tests for the composition boundary. Ve
See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary.
The implemented joint composition, local-owner routing, private contexts, host adapters, and focused views for Customisation Sync and Hidden File Sync are documented in [Optional-file synchronisation architecture](docs/design_docs/optional_file_sync_architecture.md).
Legacy Modules remain grouped by directory:
- `core/` contains platform-independent core behaviour;
@@ -169,7 +175,12 @@ Legacy Modules remain grouped by directory:
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact as-built ownership and shutdown boundaries are recorded in Commonlib's `docs/p2p-transport-lifecycle.md` design document.
See [Replicator architecture](docs/design_docs/replicator_architecture.md) for
the implemented provider contract, active Replicator lifecycle, publication and
session fences, P2P ownership exception, compatibility boundaries, and the
steps required to add a built-in provider.
Commonlib owns one stable `LiveSyncP2PService`, its `P2PRoomSessionOwner`, and the replaceable Trystero room session. Host commands, event handlers, and views consume the focused transport, connection-probe admission, directory, peer-admission, transfer, change-relay, configuration, and diagnostic views returned by the service feature. They must not retain the deprecated compatibility Replicator as an ordinary service locator, close Trystero-owned raw peers, or install another Trystero transport generation at the application root. The exact implemented ownership and shutdown boundaries are recorded in Commonlib's [P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md) design document.
### Conflict Merge Policy
@@ -254,14 +265,14 @@ Existing legacy Modules continue to register their handlers in `onBindFunction()
`Plugin.addSettingTab()`. Register a settings tab which reads persisted values
from the sequential `onSettingLoaded` lifecycle, seed its editing snapshot
before registration, and keep definition construction independent of local
database and replicator readiness. See
database and Replicator readiness. See
[the declarative settings adapter ADR](docs/adr/2026_08_declarative_settings_adapter.md).
- Use `this.services.setting.saveSettingData()` instead of using plugin methods directly
### Database Operations
- Local database operations through `LiveSyncLocalDB` (wraps PouchDB)
- Document types: `EntryDoc` (files), `EntryLeaf` (chunks), `PluginDataEntry` (plugin sync)
- Document types are owned by Commonlib. `EntryDoc` covers file Metadata, Chunks, database version information, Milestone information, Node information, and Chunk Packs. Current Customisation Sync data uses ordinary chunked Metadata in the `ix:` namespace rather than the application-local `PluginDataEntry` interface.
## Important Files
+21 -7
View File
@@ -1,9 +1,23 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Architectural Decision Record: P2P Room and Transport Lifecycle
## Status
Accepted — implemented and verified through Commonlib owner tests, the Compose transport suite, and the real-Obsidian setup workflow.
The stable P2P service and room-session owner accepted in
[Replicator Capabilities and Lifecycle Orchestration — Part 2](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
supersede only this record's replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`. This record remains
authoritative for serialised room operations, `room.leave()`, Trystero-owned
physical peers, and relay reconnection.
## Context
Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signalling, and WebRTC transport. Three related resources have different owners and lifetimes:
@@ -12,7 +26,7 @@ Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signallin
- Trystero owns the underlying WebRTC peers and may share one physical peer across more than one room; and
- Trystero's Nostr relay manager owns WebSocket clients shared by relay URL.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync replicator from discovering the same remote peer again.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync Replicator from discovering the same remote peer again.
Room departure and physical transport destruction are not equivalent. `room.leave()` sends the room-leave action, removes that room's actions and callbacks, and detaches its shared-peer binding. Trystero may retain a healthy physical WebRTC peer for later reuse after the last room binding has gone. The retained peer cannot carry actions for the room which has been left.
@@ -40,7 +54,7 @@ The explicit disconnect operation therefore has the following contract:
This operation is a logical LiveSync disconnection and a physical signalling-server disconnection. It does not promise that every browser-owned WebRTC object has been destroyed synchronously.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the current service-feature result at the point of use rather than retaining an obsolete replicator.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync Replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the focused views returned by the current P2P `serviceFeature` at the point of use rather than retaining an obsolete Replicator.
Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close requested while an open is in progress must leave no orphan room serving, and repeated opens must not create parallel rooms. No fixed delay is inserted between close and open: readiness is determined by the actual lifecycle operation and peer discovery.
@@ -50,7 +64,7 @@ P2P setup follows the transport's actual ownership model. Initialising the first
## Ownership
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the current Commonlib service-feature result and supplies the platform services used by its current replicator.
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the focused views returned by the current Commonlib P2P `serviceFeature` and supplies the platform services used by its current Replicator.
Self-hosted LiveSync does not add a separate root Trystero dependency. Tests which must observe relay sockets resolve the exact Trystero generation owned by the locked Commonlib package, avoiding two independent transport singletons in one process.
@@ -58,7 +72,7 @@ Self-hosted LiveSync does not add a separate root Trystero dependency. Tests whi
### Close every value returned by `room.getPeers()`
This bypasses Trystero's shared-peer manager and can prevent a replacement replicator from rediscovering the same peer.
This bypasses Trystero's shared-peer manager and can prevent a replacement Replicator from rediscovering the same peer.
### Add a fixed close-to-open delay
@@ -76,15 +90,15 @@ This interferes with Trystero's shared relay clients. The public pause and resum
Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current Replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync Replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The real-Obsidian P2P Setup URI workflow creates the first device, generates the second-device URI from it, accepts each peer visibly, and verifies a two-way note round-trip through a local relay. A separate focused pane test covers the principal connection control and teardown without requiring a remote peer. Transport replacement and relay-socket lifecycle remain owned by the package and Compose tests rather than being duplicated in Obsidian.
## Consequences
- Replacing a P2P replicator no longer leaves host views or commands bound to an obsolete instance.
- Replacing a P2P Replicator no longer leaves host views or commands bound to an obsolete instance.
- Explicit signalling-server disconnection has a testable socket-level meaning without claiming immediate destruction of idle WebRTC objects.
- Settings which change the relay, room, passphrase, or TURN configuration can replace the whole LiveSync room safely.
- Trystero may reuse healthy peers across room lifecycles, reducing unnecessary renegotiation.
@@ -72,6 +72,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
### Flag-file recovery order
- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead.
- Admit configured-only start-up work at priority 1, after ordinary priority-0 layout integration and before flag-file recovery. An unconfigured Vault offers onboarding and returns `false`, so recovery, compatibility review, database preparation, and configured-only request handling do not run. Treat this admission as a property of the current plug-in process: changing `isConfigured` from `false` to `true` requires the scheduled restart before configured work becomes available, and declining that restart deliberately leaves the current process inert. If an admitted process changes `isConfigured` to `false`, retire the Config Doctor and incomplete-document repair request handlers immediately, and recheck the current setting and database readiness when either handler runs.
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
@@ -100,4 +101,4 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue.
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
@@ -1,8 +1,8 @@
---
date: 2026-08-27
commonlib-version: "0.1.20"
self-hosted-livesync-version: "1.0.21"
status: proposed
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 1 of 3
---
@@ -15,15 +15,20 @@ then [Part 3: migration plan and verification](2026_08_replicator_capabilities_0
## Status
Proposed. This record defines the provider, capability, lifecycle, interaction,
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
This record defines the provider, capability, lifecycle, interaction,
ownership, and probe boundaries required by current Self-hosted LiveSync
consumers. It is the generic part of the series; the P2P-specific ownership
rules live in Part 2, and implementation sequencing lives in Part 3.
rules live in Part 2, and the completed implementation sequence lives in Part
3. The current structure is summarised in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document.
The accepted P2P room and transport lifecycle record remains authoritative for
the current P2P implementation until Stage 3 in Part 3 is complete. The
supersession boundary for that record is stated in Part 2 and is not repeated
here.
Stage 3 in Part 3 is complete. The stable P2P service and room-session owner in
Part 2 supersede the replaceable LiveSync P2P Replicator ownership described by
the earlier P2P room and transport lifecycle record. That accepted record
remains authoritative for its retained Trystero room, physical-peer, and relay
ownership decisions.
## Context
@@ -235,12 +240,14 @@ supplies an exhaustive definition table for that set. The current catalogue is
CouchDB, Object Storage, and P2P; it is not a public third-party registration
API.
CouchDB is part of every current host composition. Object Storage and P2P are
compile-time composition choices and may be included or omitted without
changing the generic scheduling feature. Adding another current provider
requires a Commonlib kind and support declaration, host composition,
Setup/profile schema handling, and provider-specific tests. It does not require
a runtime plug-in registry or behaviour for unknown provider kinds.
Every current `LiveSyncBaseCore` host composes CouchDB and Object Storage.
`WebPeerRuntime` is a separate P2P-only host composition, and P2P remains a
compile-time feature choice for the other hosts. A host can include or omit a
provider without changing the generic scheduling feature. Adding another
current provider requires a Commonlib kind and support declaration, host
composition, Setup/profile schema handling, and provider-specific tests. It
does not require a runtime plug-in registry or behaviour for unknown provider
kinds.
Each provider definition supplies:
@@ -777,6 +784,7 @@ when every caller proves it to be the operation's identity.
## References
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
@@ -1,8 +1,8 @@
---
date: 2026-08-27
commonlib-version: "0.1.20"
self-hosted-livesync-version: "1.0.21"
status: proposed
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 2 of 3
---
@@ -14,20 +14,24 @@ first, then continue with [Part 3: migration plan and verification](2026_08_repl
## Status
Proposed. This record defines the P2P service owner, room-session boundary,
narrow contract views, automation demands, replacement fencing, and trigger
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
This record defines the P2P service owner, room-session boundary, narrow
contract views, automation demands, replacement fencing, and trigger
semantics. Generic provider and capability rules are owned by Part 1; the
implementation and verification order is owned by Part 3.
completed implementation and verification order is recorded in Part 3.
The implemented state is recorded separately in Commonlib's
`docs/p2p-transport-lifecycle.md` design document. It supersedes the
replaceable LiveSync P2P Replicator and current-result ownership described by
[P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md)
design document and summarised with the generic provider lifecycle in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document. It supersedes the replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`, as described by
the accepted [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
record. The accepted record's decisions about serialised room operations,
record.
The accepted record's decisions about serialised room operations,
`room.leave()`, Trystero-owned physical peers, and relay reconnection remain in
force. This ADR remains the decision and migration target; the Commonlib
design document records the names and ownership boundaries which actually
landed.
force. This ADR records the product decision; the Commonlib design document
records the implemented names and ownership boundaries.
## Scope and context
@@ -397,6 +401,7 @@ and does not publish a second P2P lifecycle owner.
## References
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
- [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
@@ -1,8 +1,8 @@
---
date: 2026-08-27
commonlib-version: "0.1.20"
self-hosted-livesync-version: "1.0.21"
status: proposed
date: 2026-09-02
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.23"
status: accepted
series: replicator-capabilities-and-lifecycle
part: 3 of 3
---
@@ -16,11 +16,14 @@ another runtime contract.
## Status
Proposed. The stages below are an implementation and verification order, not
independently releasable states. Commonlib and Self-hosted LiveSync must not
publish temporary support boundaries described by an incomplete stage. A
release follows only after the target matrix, ownership boundaries, and the
contracted production-consumer migrations in Parts 1 and 2 are complete.
Accepted and implemented in Commonlib 0.1.21 and Self-hosted LiveSync 1.0.23.
The stages below record the implementation and verification order; they were
not independently releasable states. The target matrix, ownership boundaries,
and contracted production-consumer migrations in Parts 1 and 2 are complete.
Items which Stage 7 explicitly defers remain separate compatibility work rather
than incomplete stages. The current result is summarised in the
[Replicator architecture](../design_docs/replicator_architecture.md) design
document.
## Migration rules
@@ -103,11 +106,11 @@ until every remaining demand has settled. The LiveSync feature-binding test
must not rely on the current registration order of equal-priority resume
handlers.
Until Stage 4 supplies target-aware unattended P2P, each host composition
declares generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
request settles without UI with an explicit blocked result. Existing AutoSync,
AutoWatch, and accepted incoming-request paths continue with the Stage 2 gate.
This is a temporary migration state, not the target matrix in Part 1.
Before Stage 4 supplied target-aware unattended P2P, each host composition
declared generic `P2P_SyncOnReplication` as `not-implemented`. Its automatic
request settled without UI with an explicit blocked result. Existing AutoSync,
AutoWatch, and accepted incoming-request paths continued with the Stage 2 gate.
This was a temporary migration state, not the target matrix in Part 1.
Apply and test the CLI scheduling precedence defined in Part 1, so the daemon
and scheduling context cannot schedule duplicate initial or recurring work.
@@ -178,10 +181,11 @@ Add ownership regressions immediately before implementation:
publishing the new database identity, while a failed candidate leaves one
observable disconnected state without reviving the fenced session.
When this stage lands, add a supersession note to the accepted P2P lifecycle
record and update `devs.md` from the replaceable concrete Replicator getter to
the stable contract views. Preserve the accepted Trystero peer and relay
ownership rules rather than rewriting their historical verification.
Completion of this stage added a bounded supersession note to the accepted P2P
lifecycle record and updated `devs.md` from the replaceable concrete Replicator
getter to the stable contract views. The accepted Trystero peer and relay
ownership rules remain in force rather than being rewritten as part of this
migration.
## Stage 4: add target-aware unattended P2P orchestration
@@ -203,14 +207,17 @@ shared-pane synchronisation.
Keep the detailed wait, session-demand, de-duplication, and session-epoch state
machine in Part 2 rather than expanding the generic provider contract. If
implementation evidence requires a refinement, amend Part 2 before completing
this stage. After Stage 3 is complete, Part 2 supersedes the
replaceable-Replicator and current-result ownership portions of the accepted
July 2026 record; its Trystero peer and relay decisions remain unchanged.
this stage. With Stage 3 complete, Part 2 supersedes the portions of the
accepted July 2026 record concerning the replaceable Replicator and ownership
of the replaceable result returned by the `serviceFeature`. Its Trystero peer
and relay decisions remain unchanged.
Commonlib's `docs/p2p-transport-lifecycle.md` design document records the
implemented Stage 3 and Stage 4 ownership, demand, automation, replacement,
and shutdown behaviour. This document remains the migration and verification
sequence rather than a second description of the implemented state.
Commonlib's
[P2P transport lifecycle](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/p2p-transport-lifecycle.md)
design document records the implemented Stage 3 and Stage 4 ownership, demand,
automation, replacement, and shutdown behaviour. This document remains the
migration and verification sequence rather than a second description of the
implemented state.
## Stage 5: separate active construction and flow-specific probes
@@ -231,8 +238,8 @@ making active construction private.
The provider-defined active-construction path is now private to
`ReplicatorService`. The public `getNewReplicator` handler remains as a
compatibility surface, but current Self-hosted LiveSync production code no
longer calls it. Its removal belongs to Stage 7 after any external compatibility
decision has been made.
longer calls it. Stage 7 reviewed its possible removal and deferred it pending
an external compatibility decision.
The current host composition has migrated CouchDB and Object Storage connection
checks, passphrase inspection, preferred-tweak reads, CLI remote status and
@@ -246,10 +253,11 @@ against the active relay binding held by the stable P2P service. The Stage 7
review identified and completed that remaining owner boundary; it did not
reopen the active-construction contract.
This position completes the Stage 5 construction and probe boundary. It is not
itself a release decision: the active-publication and truthful-attempt work in
Stage 6 remains required. Complete retirement of the compatibility facade is
not a prerequisite for issue 1140.
This position completed the Stage 5 construction and probe boundary. At that
intermediate point it was not itself a release decision: Stage 6 still had to
complete the active-publication lifecycle and return an exact outcome for each
attempt. Complete retirement of the compatibility facade was not a prerequisite
for issue 1140.
## Stage 6: harden the active lifecycle and exact attempt outcome
@@ -342,8 +350,8 @@ publication.
The first implementation regressions cover:
- replacement waiting for an admitted exact-context task while ignoring an
unrelated bounded activity;
- replacement waiting for a task admitted against the exact publication while
ignoring an unrelated bounded activity;
- context acquisition waiting for a queued replacement rather than returning a
stale or intermediate publication;
- rejecting central-remote administration releasing its reservation before
@@ -711,6 +719,7 @@ retirement remains a separately reviewed compatibility change.
## References
- [Project glossary](../glossary.md#developer-and-design-terms)
- [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
- [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
@@ -0,0 +1,553 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Architectural Decision Record: Customisation Sync and Hidden File Sync ownership
## Status
Accepted and implemented. The completed structural stages
extract the Customisation Sync path and codec operations, characterise the
current shared routing behaviour, remove the production UI dependency cycle,
move host presentation and Hidden File Sync commands into serviceFeatures,
give one optional-file serviceFeature ownership of both runtime contexts and
all Service handler registration, and replace both runtimes' complete-core
dependencies with narrow host adapters. A pure local-path policy now selects
one writer for raw events, scheduled scans, and automatic database-to-local
reflection. The constructor-name add-on lookup and the `ConfigSync` and
`HiddenFileSync` add-on identities have been retired.
The Customisation Sync private context coordinates lifecycle, raw-event
admission, configuration and periodic policy, view composition, and the
lifetimes of focused path, snapshot, application, scan, and catalogue owners.
The Hidden File Sync private context coordinates lifecycle and handler
admission around focused path-admission, notification, processed-state,
change-processing, conflict-resolution, and reconciliation owners. Each
receives live settings and database projections, focused storage, path, and
exact-revision capabilities, and explicit host effects rather than
`LiveSyncCore`.
The corresponding implemented topology is documented in
[Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md).
## Context
Customisation Sync and Hidden File Sync are supported, advanced, opt-in
features with different data and interaction contracts:
- Customisation Sync stores device-scoped snapshots in the `ix:` namespace.
It supports grouped V1 documents and per-file V2 documents, presents a
catalogue, and applies selected remote state only after an explicit action.
- Hidden File Sync stores one mirrored hidden file in each `i:` Metadata
document. It scans and reconciles local and database state, applies eligible
remote changes automatically, and maintains device-local processed-state
records for offline and conflict handling.
In the baseline implementation, the `ConfigSync` and `HiddenFileSync` add-ons
each combined domain operations, mutable state, Service handler registration,
lifecycle work, settings policy, commands, and Obsidian presentation. Both
received the complete core through `LiveSyncCommands`, and both registered
handlers for optional file events, conflicts, settings, application lifecycle,
database initialisation, and replication.
Some of those Commonlib handlers short-circuit at the first successful or
non-empty result. The construction order of `ConfigSync` before
`HiddenFileSync` is therefore observable behaviour. The Obsidian storage-event
manager also asks the Vault Service whether a configuration-directory file is
an extra target, while the corresponding predicate is currently registered by
`HiddenFileSync`. Local file ownership is consequently distributed across the
storage-event manager, both add-ons, settings entries, target and ignore
patterns, and handler registration order.
In the baseline implementation, user-interface, maintenance, and
real-Obsidian E2E consumers located the concrete add-ons through `getAddOn()`.
Removing either add-on before migrating those consumers would therefore have
combined an ownership change, a lifecycle change, and a consumer migration in
one step.
The baseline Customisation Sync presentation also formed a runtime import
cycle: `ConfigSync` imported its Obsidian dialogue, the dialogue imported its
Svelte pane, and the pane imported `ConfigSync` and `HiddenFileSync` to locate
both add-ons. The child row component imported `ConfigSync` again. The settings
tab also imported `main.ts` as a runtime binding even though it used the
plug-in class only as a type. Bundling survived because those bindings were
read after module evaluation, but that timing was not an architectural
contract. Presentation therefore had to move off the concrete add-ons before
the domain runtimes could be extracted.
## Decision drivers
The redesign must:
1. preserve `ix:` and `i:` data, including V1 and V2 Customisation Sync data;
2. give each local path at most one synchronisation owner;
3. remove handler registration order as the source of path ownership;
4. preserve explicit Customisation Sync application and automatic Hidden File
Sync reflection as separate behaviours;
5. make mutable state and resource disposal ownership explicit;
6. keep Obsidian presentation outside host-neutral operations;
7. migrate existing add-on consumers to focused views before retiring add-on
identity; and
8. permit each migration stage to be verified independently.
## Decision
### Retain two domain runtimes
Customisation Sync and Hidden File Sync will remain separate domain runtimes.
They will not be implemented as modes of one generic file-sync engine.
The Customisation Sync runtime will own:
- V1 and V2 codecs and document-path compatibility;
- the device catalogue and snapshot repository;
- scanning, migration, logical deletion, comparison, and explicit apply
operations; and
- the state required to serialise and report those operations.
The Hidden File Sync runtime and its composed focused owners will own:
- the device-local processed-state records;
- one-file storage and database transfer, including exact-revision repair;
- start-up, offline, periodic, and pre-replication reconciliation;
- conflict queues and automatic or interactive JSON resolution; and
- the state required to serialise and report those operations.
Neither runtime will accept `LiveSyncBaseCore` as its domain dependency.
Operations will receive narrow Services, ServiceModules, settings projections,
and host effects.
### Compose one owner for overlapping handlers
One joint serviceFeature constructs both private runtime contexts and owns
registration into the overlapping optional-file, conflict, target, settings,
database, replication, and application-lifecycle handlers. A single
optional-file callback selects its local owner before invoking either context,
and a single conflict callback dispatches the disjoint `ps:`, `ix:`, and `i:`
namespaces. Callback order and add-on construction order no longer select the
local file owner. Config-before-Hidden order remains explicit only for shared
lifecycle handlers whose side effects still require compatibility.
The composition feature will remain small. Codecs, path functions,
repositories, state transitions, and transfer operations are ordinary modules
or private contexts rather than separate serviceFeatures. No new
ServiceModule is justified while those capabilities have one composition
owner and no independent long-lived consumers.
Internally, operations will use a typed settlement which distinguishes at
least handled, skipped, and failed work. The composition boundary will adapt
that settlement to the existing Commonlib Boolean or first-result handler
contracts. Existing short-circuit and failure behaviour must be characterised
before an adapter changes it.
### Make local path ownership explicit
A pure routing policy classifies a local path as owned by Customisation
Sync, owned by Hidden File Sync, or ignored with a reason. Its inputs will
include:
- the Obsidian configuration directory;
- whether each feature is enabled and ready;
- the Customisation Sync category and extended mode;
- Hidden File Sync target and ignore patterns; and
- the ignore-file result where that asynchronous policy applies.
The maintained ownership intent is:
| Path and mode | Local owner |
| ------------------------------------------------------------ | ------------------ |
| Recognised Customisation Sync path in Selective mode | Customisation Sync |
| Recognised Customisation Sync path in Flagged Selective mode | Customisation Sync |
| Recognised Customisation Sync path in Automatic mode | Hidden File Sync |
| Recognised Customisation Sync path in Ignore mode | Neither feature |
| Other eligible hidden path | Hidden File Sync |
| Excluded, ignored, disabled, or ordinary Vault path | Neither feature |
This table is now used for raw-event dispatch and is injected into both
contexts for scheduled or database-driven local work. Default and persisted
Selective entries therefore have the same Customisation Sync owner. Hidden
File Sync target patterns and ignore-file results are evaluated only after the
policy selects Hidden File Sync, so they cannot prevent a Selective
Customisation Sync event. Automatic mode has no local owner while Hidden File
Sync is disabled, and Ignore mode has no local owner in either context.
Commonlib's ordinary event queue rejects dot paths while Hidden File Sync is
disabled. The Obsidian raw-event boundary therefore dispatches an event which
the policy assigns to Customisation Sync directly after retaining the queue's
configuration, suspension, and modification-time gates. Events handled while
Hidden File Sync is enabled continue through the ordinary queue.
This is a local-write routing table, not a database-document acceptance table.
Existing `ix:` documents must remain recognisable after a local mode change,
and they must not fall through to ordinary Vault reflection. Existing `i:`
documents retain their own namespace and selection rules. The implementation
therefore keeps namespace-specific database handlers separate from the
local-path policy rather than exposing one general `isTargetPath()` predicate.
A narrower document decision controls whether a local Customisation Sync scan
may mutate an existing `ix:` document; it does not control whether that
document is recognised or consumed.
### Use private contexts and focused views
Each mutable object will have one named owner. Catalogue stores, manifest
caches, processed-state records, recent-event records, locks, semaphores,
queues, periodic processors, Notices, and event subscriptions must be created
and disposed by their feature context or a focused resource owner.
The contexts are orchestration roots, rather than containers for every mutable
detail. `HiddenFileSyncProcessedState` owns the three device-local maps, their
autosave initialisation, exact key formats, retained known mtime, reset rules,
and settlement effects on `IPathService`. Database write and extraction
operations receive this capability through one narrow `processedState` port;
they do not receive bundles of individual state callbacks or implement state
keys themselves.
`CatalogueState` separately owns the transient catalogue rows, manifest lookup
and mtime cache, their reactive stores, and catalogue update progress. A small
recent-event deduplicator owns raw-event admission history. These Customisation
Sync owners are deliberately not implementations of a shared Hidden File Sync
state abstraction: the former state is a derived, in-memory projection, while
Hidden File Sync markers are persisted operational reconciliation state with
different identity, invalidation, and deletion rules.
`CatalogueOperations` owns catalogue enumeration, one queue and its progress
subscription, and composes `CatalogueV1`, `CatalogueV2`, and
`CatalogueMigration`. V1 and V2 are mutually exclusive as the selected write
format, but both persisted formats can coexist during migration. Queue work
therefore reads the live setting when it starts, and the shared state continues
to recognise V2 rows independently of that setting. `CatalogueMigration`
remains the distinct bridge from grouped V1 binders to per-file V2 documents.
`SnapshotPersistence` owns the host-neutral V1 grouped and V2 per-file writes
and logical deletion. It returns explicit mutation and refresh outcomes, so it
neither owns catalogue state nor calls back through the context.
`SnapshotOperations` applies those outcomes with the inherited awaited V1 or
fire-and-forget V2 timing. `ApplicationOperations` owns compare, apply,
duplicate, and delete workflows, while `ScanOperations` owns configuration-file
enumeration and V1/V2 reconciliation. Both depend on narrow snapshot and
catalogue ports instead of the context.
The newly extracted catalogue, snapshot, application, scan, and reconciliation
modules omit a Customisation Sync or Hidden File Sync prefix because their
feature directories already supply that scope. Public contexts and views retain
their domain names for compatibility.
`CustomisationSyncPathOperations` binds live configuration-directory, mode,
and device-name projections to the pure category and V1/V2 key functions. It
has no host registration or stateful application lifetime. The context uses
this capability internally; its path helpers are not re-exported through the
real-Obsidian testing view.
`HiddenFileSyncPathAdmission` owns the ownership-first eligibility sequence
and its parsed-pattern cache. `HiddenFileSyncChangeNotifier` owns the pending
folder set, delayed delivery, suppression checks, scheduled-task cancellation,
and Notice show/hide effect calls. The Obsidian adapter still owns the actual
Notice instance. These owners make cache and notification behaviour directly
testable without making either concern a serviceFeature.
`HiddenFileSyncChangeProcessor` owns storage and database change processing,
the bounded semaphore, same-path event serialisation, activity counts, and the
inherited order in which processed-state markers and transfer results settle.
This boundary keeps event concurrency and settlement directly testable without
giving the processor full scan, initialisation, notification, or host
responsibilities.
`Reconciliation` owns storage and database enumeration, full scans, offline
comparison, rebuild direction and ordering, processed-state adoption,
initialisation sequencing, and the scoped rebuild interceptor used by maintained
real-Obsidian tests. Storage and database scans remain together because every
offline and initialisation path coordinates both sides.
The joint composition may return several views backed by those contexts:
- a Customisation Sync catalogue and operation view for its dialogue;
- a Hidden File Sync initialisation view for settings workflows;
- a Hidden File Sync repair view for the Hatch pane;
- immutable semantic handler views for registration by the joint composition;
and
- explicitly internal testing views for maintained real-Obsidian workflows.
Several views over one context do not create several owners. Views expose
stable application data and named operations rather than PouchDB entries,
queue objects, mutable settings records, dependency objects, or the complete
core. The testing views also avoid exposing context instances or writable
internal state; time-sensitive E2E work uses a scoped operation interceptor.
Obsidian commands, ribbon actions, dialogues, Notices, plug-in reloads, and
restart scheduling will remain in host-owned composition. The UI will receive
focused views instead of locating `ConfigSync` or `HiddenFileSync` and calling
one add-on from the other.
The Customisation Sync dialogue has its own host-owned presentation
serviceFeature. It owns command, ribbon, event subscription, dialogue reuse,
and dialogue disposal, and consumes the catalogue and Hidden File Sync
initialisation views. Neither domain runtime imports its Obsidian dialogue or
Svelte components. This presentation feature is separate from the joint
synchronisation owner because it registers host UI rather than overlapping
file, conflict, or replication handlers.
Hidden File Sync commands and their setting-change event subscription are
owned by a second host serviceFeature. It consumes only the command view and
releases its lifecycle and event registrations during application unload.
### Retire compatibility façades after consumer migration
The concrete `ConfigSync` and `HiddenFileSync` add-on identities were retained
only until their consumers and handler ownership had migrated. They have now
been replaced by private `CustomisationSyncContext` and
`HiddenFileSyncContext` instances constructed solely by the joint
serviceFeature. `LiveSyncBaseCore.getAddOn()` and its constructor-name lookup
have been removed.
Production code consumes focused views and does not use the broad context
surface. Maintained real-Obsidian E2E workflows use explicitly internal,
immutable test views exposed by the composed feature. These are transitional
test seams, not production service locators, and they should be narrowed as
those workflows move to public operations or commands.
The retirement was gated on:
- production UI and maintenance consumers no longer use `getAddOn()` for these
features;
- maintained E2E helpers use commands or an explicit test view;
- no handler depending on add-on construction order; and
- unload and replacement tests prove that every owned processor,
subscription, queue, dialogue, and Notice is released.
## Persisted compatibility
This decision does not authorise a data migration.
- `ix:` remains the Customisation Sync Metadata namespace.
- `i:` remains the Hidden File Sync Metadata namespace.
- Metadata continues to reference Chunks rather than embedding raw file
content as an ordinary Metadata field.
- V1 grouped Customisation Sync documents remain readable.
- V2 per-file Customisation Sync paths and the existing V1-to-V2 migration
remain readable and idempotent.
- Device and Vault terms, logical deletions, revision identifiers, and current
path derivation remain compatibility inputs.
- Hidden File Sync processed-state keys remain device-local state unless a
separately tested migration is introduced.
Pure extraction must preserve exact case, depth, prefix, delimiter, and
fallback behaviour even where a later correction appears desirable.
## Lifecycle and failure boundaries
Feature composition will occur after required Services and ServiceModules
exist and before lifecycle-driven work begins. Command and Obsidian UI
registration will occur at the readiness point required by the host.
The owner will:
- start periodic work only while its feature is enabled, ready, and resumed;
- coalesce or serialise work within the feature instance rather than through
process-global string keys where practical;
- stop admission before disposing queues or processors;
- unsubscribe every registered local event listener; and
- close owned dialogues and Notices during unload.
A failed operation must not be converted to handled success merely to stop a
later handler, unless a characterisation test proves that the existing
contract deliberately consumes that failure. Such compatibility adaptations
must be visible at the composition boundary.
## Migration and verification sequence
### Stage 1: characterise pure and routing contracts — implemented
- Cover current Customisation Sync category and V1/V2 document-path rules.
- Cover codec round trips, legacy JSON and YAML fallbacks, and migration
sentinels before extracting the codec.
- Record the effective Selective, Automatic, Ignore, and Flagged Selective
routing matrix, including feature-disabled and pattern-excluded cases.
- Record handler registration, short-circuiting, and teardown behaviour.
### Stage 2: extract pure operations and the presentation boundary — implemented
- Move Customisation Sync path and document-key functions behind the existing
façade methods.
- Move the V1/V2 codec with its hash and YAML dependencies made explicit.
- Inject catalogue, initialisation, and repair views into production UI.
- Move Customisation Sync command, ribbon, event subscription, and dialogue
lifetime into a host-owned presentation serviceFeature.
- Prohibit presentation imports of either concrete add-on or the application
core, and prohibit the runtime from importing its dialogue.
### Stage 3: make routing ownership explicit — implemented
- Introduce the pure routing policy, initially adapting both runtime contexts
to it at the joint composition boundary.
- Preserve the characterised legacy routing until each discrepancy has its own
behavioural decision and regression test.
One optional-file handler now dispatches exactly one selected context, and one
namespace router handles optional conflicts. Both contexts receive the same
static ownership projections for raw events and scans. The final raw-event
decision additionally includes lifecycle readiness, Hidden File Sync patterns,
and the asynchronous ignore-file result. Handler failure does not fall through
to the non-owner.
### Stage 4: extract the Customisation Sync runtime — implemented
- Move catalogue, snapshot repository, scan, apply, compare, delete, and
migration operations into a private context.
- Replace module-global mutable state with context-owned state.
- Replace the complete-core dependency with narrow dependencies.
The private context, path module, codec module, focused presentation view, and
resource teardown are implemented. A focused path capability binds live
settings and device identity to the pure path functions. A focused catalogue
owner holds one queue, its progress subscription, and shared state, while
separate V1, V2, and migration modules hold format-specific behaviour. A
bounded deduplicator owns recent raw-event keys. Host-neutral snapshot
persistence owns V1 grouped and V2 per-file writes, unchanged-content checks,
and logical deletion. A snapshot coordinator applies its explicit refresh
outcomes without a catalogue-to-context callback cycle. Focused application
and scan owners contain selected-snapshot workflows and full local/database
reconciliation, respectively. The context retains raw-event admission and
scheduling, configuration and periodic policy, owner lifetime, and view
composition. It accepts only narrow, live projections and explicit effects; an
Obsidian adapter at the composition edge owns dialogues, Notices, plug-in
reload, restart, lifecycle, Vault access, and compatibility scan telemetry.
### Stage 5: extract the Hidden File Sync runtime — implemented
- Move processed-state, transfer, reconciliation, conflict, and notification
operations into explicit owners.
- Preserve exact-revision repair and current initialisation directions.
- Replace the complete-core dependency with narrow dependencies.
The private context, focused initialisation, repair, and command views,
host-owned command registration, and processor, cache, subscription, and
Notice teardown are implemented. A focused processed-state owner holds all
three persisted maps, their key and mtime rules, reset operations, and
cross-side settlement. Database write and extraction operations consume one
narrow state port. A focused path-admission owner holds the pattern cache and
the ownership, static-path, pattern, and ignore-file sequence. A focused change
notifier owns folder batching, delayed delivery, and teardown of its scheduled
work and Notice effect. A focused change processor owns storage and database
event processing, bounded concurrency, per-path serialisation, activity
publication, and compatibility settlement order. A focused reconciliation
owner owns storage and database scans, offline comparison, rebuilds,
processed-state adoption, initialisation direction and ordering, and its scoped
testing interceptor. A focused conflict-resolution owner owns pending-path admission,
the parallel classification and serial interaction queues, automatic merge,
newer-revision selection, interactive JSON application, settlement, and queue
disposal. An Obsidian adapter owns JSON conflict dialogue instances, progress
presentation, grouped Notices, plug-in reload, restart scheduling, Vault
enumeration, and compatibility activity publication.
### Stage 6: move synchronisation composition — implemented
- Register the overlapping Service handlers once through the joint
serviceFeature.
- Consume immutable semantic handler views rather than exposing registry-style
methods on either context.
- Preserve the characterised lifecycle callback order and Commonlib
aggregation semantics through focused tests.
### Stage 7: retire the façades — implemented
- Remove add-on identity and constructor-order dependencies.
- Migrate maintained E2E workflows to the explicit feature test boundary.
- Remove constructor-name service lookup from the core.
The implemented topology is documented separately from this migration record
in [Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md).
Each stage will run focused unit tests. Changes to Customisation Sync and
Hidden File Sync will run their respective real-Obsidian E2E workflows. The
composition switch will additionally require a mixed-ownership workflow which
proves that one local path is never written by both features.
## Non-goals
This migration does not:
- merge the two persisted namespaces or sync models;
- move implementation into Commonlib before another maintained host needs the
capability;
- change feature maturity, default enablement, setup, or initialisation;
- replace current conflict policy with a generic conflict engine;
- rename existing setting keys, command identifiers, or user-interface labels;
or
- correct unrelated suspicious behaviour while extracting code.
## Characterisation gates
Stage 3 resolved the routing-specific gates with focused regressions:
- an unrecognised eligible hidden path is owned by Hidden File Sync;
- default and persisted Selective entries are owned by Customisation Sync;
- raw Customisation Sync remains available while Hidden File Sync is disabled;
- Hidden File Sync patterns and ignore-file results gate only its selected
paths;
- Automatic mode without an enabled and ready Hidden File Sync owner is
ignored rather than falling back to Customisation Sync;
- a selected handler which skips or fails does not fall through to the other
context; and
- Customisation Sync raw admission now invokes the readiness predicate and
rejects unrecognised or non-owned paths.
The extraction preserves the existing V1 and V2 plug-in application paths,
exact-revision repair, initial cache conditions, and the selected Hidden File
Sync database-processing settlement. These remain compatibility gates for any
future behavioural change. Any defect correction requires its own failing
regression.
## Alternatives rejected
### Convert each extracted file into a serviceFeature
This would reproduce distributed registration and lifetime ownership under
more function names. Pure operations and one private context are the narrower
boundary.
### Merge both features into one generic hidden-file engine
This would obscure the explicit-apply snapshot contract, the automatic mirror
contract, and their incompatible persistence and conflict semantics.
### Remove both add-ons before characterisation and consumer migration
This would change identity, ordering, lifecycle, UI, maintenance, and E2E
boundaries simultaneously. Retaining identity through the earlier migration
stages gave each structural change a smaller failure surface.
### Move the runtimes to Commonlib first
There is no second maintained consumer for the complete feature behaviour at
present. Moving the monoliths across the package boundary would enlarge the
migration without first establishing narrow dependencies.
## Consequences
- Local path ownership can become explicit at one composition boundary.
- Persisted formats and supported user workflows remain stable during the
migration.
- The composition root gains several focused views but does not gain another
runtime service locator.
- The legacy add-on identity and constructor-name lookup are removed.
- The two contexts coordinate separate synchronisation workflows, but their
dependency surfaces are explicit and do not include the complete core.
Customisation Sync delegates its path binding, snapshot persistence and
refresh sequencing, selected-snapshot application, scan reconciliation,
derived catalogue, catalogue queue, and recent-event state, while Hidden File
Sync delegates path admission, notification, processed-state,
change-processing, reconciliation, and conflict lifecycles to focused owners.
Further extraction should follow a concrete behavioural boundary rather than
create additional serviceFeatures for private operations.
## References
- [Feature maturity for 1.0](2026_07_feature_maturity_for_1_0.md)
- [Service feature and legacy Module boundaries](../design_docs/service_feature_and_legacy_module_boundaries.md)
- [Optional-file synchronisation architecture](../design_docs/optional_file_sync_architecture.md)
- [Hidden File Sync guide](../tips/hidden-file-sync.md)
- [Settings reference](../settings.md#6-customisation-sync-advanced)
- [Development guide](../../devs.md#service-composition-and-legacy-modules)
+193 -144
View File
@@ -1,173 +1,222 @@
# Data Structures of Self-Hosted LiveSync
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
## Overview
# Database Data Structures
Self-hosted LiveSync uses the following types of documents:
## Scope and Authority
- Metadata
- Legacy Metadata
- Binary Metadata
- Plain Metadata
- Chunk
- Versioning
- Synchronise Information
- Synchronise Parameters
- Milestone Information
This document is a developer overview of the database structures used by the
current Self-hosted LiveSync 1.0 series. It is not a stable, forward-compatible
API for constructing CouchDB documents by hand.
## Description of Each Data Structure
The executable authority for document types, path and identifier encoding,
chunk splitting and hashing, encryption, compression, and content
reconstruction is the exact `@vrtmrz/livesync-commonlib` version recorded in
the repository lockfile. Commonlib owns this domain under the
[package-boundary decision](adr/2026_07_common_library_package_boundary.md).
When this overview and that installed package differ, correct this document
and treat the package behaviour as authoritative for the affected release.
All documents inherit from the `DatabaseEntry` interface. This is necessary for conflict resolution and deletion flags.
Three representations must be distinguished:
1. the decoded application representation used by Commonlib services;
2. the local PouchDB representation, including CouchDB revision metadata; and
3. the raw remote representation after any configured compression, E2EE, or
path-obfuscation transform.
The examples below describe the first two representations unless a section
explicitly discusses the raw remote representation. The exact raw remote shape
depends on the configured transforms and protocol version and cannot be
inferred from the decoded or local examples alone.
## Principal Document Families
- file Metadata, including compatibility-only legacy Metadata;
- Chunks and compatibility transport structures such as Chunk Packs;
- database version, synchronisation, Milestone, and Node information; and
- CouchDB revision and deletion records.
Commonlib's `EntryDoc` is a union across several of these families. It is not
synonymous with file Metadata.
## Common CouchDB Fields
Database documents share this base shape:
```ts
export interface DatabaseEntry {
_id: DocumentID;
_rev?: string;
_deleted?: boolean;
_conflicts?: string[];
}
```
### Versioning Document
- `_id` identifies one CouchDB document.
- `_rev` identifies one revision of that document.
- `_conflicts` is returned when conflict information is requested. It is
CouchDB revision metadata, not part of the persisted application document.
- `_deleted: true` creates a CouchDB tombstone. It is distinct from the
logical file-deletion field `deleted: true` described below.
This document stores version information for Self-hosted LiveSync.
The ID is fixed as `obsydian_livesync_version` [VERSIONING_DOCID]. Yes, the typo has become a curse.
When Self-hosted LiveSync detects changes to this document via Replication, it reads the version information and checks compatibility.
This internal database version is independent of the plug-in's SemVer version. The last version explicitly acknowledged on a device is stored through Commonlib's device-local configuration contract. When that version differs, or when a settings migration requires review, Self-hosted LiveSync presents a dedicated compatibility dialogue and blocks replication without changing the user's automatic synchronisation choices. A supported upgrade can resume only after explicit review. A downgrade from a newer acknowledged database version, or settings written by a future schema, remains blocked until a compatible plug-in is installed.
Please refer to negotiation.ts.
## File Metadata
### Synchronise Information Document
This document stores information that should be verified in synchronisation settings.
The ID is fixed as `syncinfo` [SYNCINFO_ID].
The information stored in this document is only the conditions necessary for synchronisation to succeed, and as of v0.25.43, only a random string is stored.
This document is only used during rebuilds from the settings screen for CouchDB-based synchronisation, making it like an appendix. It may be removed in the future.
### Synchronise Parameters Document
This document stores synchronisation parameters.
Synchronisation parameters include the protocol version and salt used for encryption, but do not include chunking settings.
The ID is fixed as `_local/obsidian_livesync_sync_parameters` [DOCID_SYNC_PARAMETERS] or `_obsidian_livesync_journal_sync_parameters.json` [DOCID_JOURNAL_SYNC_PARAMETERS].
This document exists only on the remote and not locally.
This document stores the following information.
It is read each time before connecting and is used to verify that E2EE settings match.
This mismatch cannot be ignored and synchronisation will be stopped.
Current files are stored as chunked Metadata. The following is a simplified
shape; the exported Commonlib declarations remain authoritative:
```ts
export interface SyncParameters extends DatabaseEntry {
_id: typeof DOCID_SYNC_PARAMETERS;
type: (typeof EntryTypes)["SYNC_PARAMETERS"];
protocolVersion: ProtocolVersion;
pbkdf2salt: string;
}
```
#### protocolVersion
This field indicates the protocol version used by the remote. Mostly, this value should be `2` (ProtocolVersions.ADVANCED_E2EE), which indicates safer E2EE support.
#### pbkdf2salt
This field stores the salt used for PBKDF2 key derivation on the remote. This salt and the passphrase provides E2EE encryption keys.
### Milestone Information Document
This document stores information about how the remote accepts and recognises clients.
The ID is fixed as `_local/obsidian_livesync_milestone` [MILESTONE_DOCID].
This document exists only on the remote and not locally.
This document is used to indicate synchronisation progress and includes the version range of accepted chunks for each node and adjustment values for each node.
Tweak Mismatched is determined based on the information in this document.
For details, please refer to LiveSyncReplicator.ts, LiveSyncJournalReplicator.ts, and LiveSyncDBFunctions.ts.
```ts
export interface EntryMilestoneInfo extends DatabaseEntry {
_id: typeof MILESTONE_DOCID;
type: EntryTypes["MILESTONE_INFO"];
created: number;
accepted_nodes: string[];
node_info: { [key: NodeKey]: NodeData };
locked: boolean;
cleaned?: boolean;
node_chunk_info: { [key: NodeKey]: ChunkVersionRange };
tweak_values: { [key: NodeKey]: TweakValues };
}
```
### locked
If the remote has been requested to lock out from any client, this is set to true.
When set to true, clients will stop synchronisation unless they are included in accepted_nodes.
### cleaned
If the remote has been cleaned up from any client, this is set to true.
In this case, clients will stop synchronisation as they need to rebuild again.
### Metadata Document
Metadata documents store metadata for Obsidian notes.
```ts
export interface MetadataDocument extends DatabaseEntry {
_id: DocumentID;
type ChunkedMetadata = DatabaseEntry & {
ctime: number;
mtime: number;
size: number;
deleted?: boolean;
eden: Record<string, EdenChunk>; // Obsolete
eden: Record<string, { data: string; epoch: number }>;
path: FilePathWithPrefix;
children: string[];
type: EntryTypes["NOTE_LEGACY" | "NOTE_BINARY" | "NOTE_PLAIN"];
}
```
### type
This field indicates the type of Metadata document.
By convention, Self-hosted LiveSync does not save the mime type of the file, but distinguishes them with this field. Please note this.
Possible values are as follows:
- NOTE_LEGACY: Legacy metadata document
- Please do not use
- NOTE_BINARY: Binary metadata document (newnote)
- NOTE_PLAIN: Plain metadata document (plain)
#### children
This field stores an array of Chunk Document IDs.
#### \_id, path
\_id is generated based on the path of the Obsidian note.
The validation and explicit repair contract for normal-file Metadata whose
actual ID does not match the ID derived from its stored path is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
- If the path starts with `_`, it is converted to `/_` for convenience.
- If Case Sensitive is disabled, it is converted to lowercase.
When Obfuscation is enabled, the path field contains `f:{obfuscated path}`.
The path field stores the path as is. However, when Obfuscation is enabled, the obfuscated path is stored.
When Property Encryption is enabled, the path field stores all properties including children, mtime, ctime, and size in an encrypted state. Please refer to encryption.ts.
### Chunk Document
```ts
export type EntryLeaf = DatabaseEntry & {
_id: DocumentID;
type: EntryTypes["CHUNK"];
data: string;
type: "plain" | "newnote";
};
```
Chunk documents store parts of note content.
`children` contains Chunk document IDs in reconstruction order. A normal save
persists every referenced Chunk before it persists the Metadata which names
those Chunks. The writes are separate database operations rather than one
atomic transaction, so another client may still observe the Metadata first.
The resulting retrieval contract is documented in
[Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md).
- The type field is always `[CHUNK]`, `leaf`.
- The data field stores the chunk content.
- The \_id field is generated based on a hash of the content and the passphrase.
The current persisted file types are:
Hash functions used include xxHash and SHA-1, depending on settings.
Chunking methods used include Contextual Chunking and Rabin-Karp Chunking, depending on settings.
- `plain`, for text content represented by literal text Chunks; and
- `newnote`, for binary content represented by Base64 Chunks.
The compatibility-only `notes` type stores content directly in its `data`
field rather than in `children`. Existing data may be read through selected
legacy paths, but current writers do not create `notes` documents, and not
every current replication path accepts newly created legacy documents.
`datatype` appears on Commonlib's loaded and saving representations. The
current Metadata writer does not persist it, so it is absent from ordinary
current CouchDB Metadata.
`eden` remains in the shared type for existing data compatibility. New
configuration does not enable Eden, and current writers do not create
incubated Eden Chunks for a new configuration.
### Times and Size
`ctime` and `mtime` are Unix epoch times in milliseconds. `size` is the byte
size of the decoded file content supplied by the storage boundary. Current
storage adapters and generated Blob paths obtain it from filesystem metadata
or `Blob.size`; JavaScript `String.length` is a UTF-16 code-unit count and is
not a valid substitute for non-ASCII content.
### Paths, Identifiers, and Namespaces
At the decoded boundary, `path` records the logical path, including any
feature namespace prefix. `_id` is derived from that path by Commonlib's path
service:
- a path beginning with `_` receives a leading `/` in its document ID so that
CouchDB does not interpret it as a reserved identifier;
- the path is folded to lower case only when
`handleFilenameCaseSensitive` is disabled; and
- when path obfuscation is enabled, the body of the document ID is replaced
by an `f:` SHA-256-derived value. A feature prefix is retained, so an
obfuscated Hidden File Sync ID can begin with `i:f:`.
The main namespaces are:
| Prefix | Meaning |
| ------ | ------------------------------------------------------- |
| none | An ordinary Vault file |
| `i:` | Hidden File Sync Metadata |
| `ix:` | Customisation Sync Metadata |
| `ps:` | Compatibility namespace for plug-in storage data |
| `f:` | Obfuscated document-ID body |
| `h:` | Chunk document |
| `h:+` | Chunk whose identifier incorporates encryption material |
Namespaces identify storage and path handling; they do not by themselves
select an Entry `type`. Current Hidden File Sync and Customisation Sync writers
store chunked `plain` or `newnote` documents under `i:` and `ix:`. The
application-local `type: "plugin"` interface is not a Commonlib Entry type and
is not the current Customisation Sync storage format. Although Commonlib
retains an `internalfile` constant for compatibility, current Hidden File Sync
producers do not use it as their persisted type.
The decoded `path` does not become `f:{obfuscated path}`. Compression, E2EE,
and path obfuscation can change raw remote identifiers and properties.
Commonlib owns those transforms, and their exact representation depends on the
selected settings.
The validation and explicit repair contract for ordinary-file Metadata whose
stored `_id` does not agree with its decoded `path` is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
## Chunk Documents
```ts
export type EntryLeaf = DatabaseEntry & {
type: "leaf";
data: string;
isCorrupted?: boolean;
};
```
A `leaf` stores one content-addressed piece. For `plain` Metadata, `data` is
literal text. For `newnote` Metadata, `data` is Base64 text representing binary
bytes. Concatenating and decoding the children in order reconstructs the
decoded file content.
Commonlib's configured `HashManager` produces content-derived Chunk
identifiers. Their representation can vary with compatibility and encryption
settings. Historical hash algorithms remain readable only as compatibility
settings.
Chunk revisions are content-derived irrespective of the obsolete stored
`doNotUseFixedRevisionForChunks` setting. Compression and E2EE may transform a
Chunk's raw remote `data` and add representation markers, so the remote value
can differ from the decoded Chunk data.
## File Deletion
LiveSync distinguishes two operations:
- `deleted: true` is a logical deletion of a file. With Metadata retention
enabled, an ordinary current-file deletion preserves the existing Metadata
fields and Chunk references, updates `mtime`, and creates a new Metadata
revision. This permits the deletion to participate in synchronisation and
conflict history.
- `_deleted: true` is a CouchDB tombstone for one document revision. That
revision does not retain the application body. Tombstones are used by
explicit compatibility and clean-up paths.
A logical deletion does not clear `children` or set `size` to zero. The
`deleteMetadataOfDeletedFiles` setting can request an immediate tombstone
instead. Whether retained, logically deleted Metadata is later tombstoned
depends on the configured deletion-retention settings.
Branch-specific conflict operations and their ancestry requirements are defined
in the [Conflict Resolution specification](specs_conflict_resolution.md).
## Control Documents
The principal control documents are:
| Document | Identifier | Purpose |
| ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Version information | `obsydian_livesync_version` | Records the internal database version. The historical spelling is retained for compatibility. |
| Synchronisation information | `syncinfo` | Stores rebuild-related synchronisation information for CouchDB-based operation. |
| CouchDB synchronisation parameters | `_local/obsidian_livesync_sync_parameters` | Stores the protocol version and PBKDF2 salt on the remote. |
| Journal synchronisation parameters | `_obsidian_livesync_journal_sync_parameters.json` | Journal counterpart of the synchronisation-parameter record. |
| Milestone information | `_local/obsidian_livesync_milestone` | Records accepted Nodes, locking, clean-up state, Chunk version ranges, and synchronisation tweak values. |
| Node information | `_local/obsidian_livesync_nodeinfo` | Records the local Node identifier and compatibility markers. |
The `_local/` records are CouchDB-local documents and do not replicate like
ordinary Metadata and Chunks. Synchronisation parameters are checked before
connecting; an incompatible protocol or encryption configuration stops
synchronisation rather than being ignored.
@@ -7,7 +7,7 @@ Accepted for a limited implementation.
## Problem and scope
This document uses the independent revision properties defined under
[Revision](../terms.md#revision) and the general state model in
[Revision](../glossary.md#revision) and the general state model in
[Conflict resolution and revision provenance](../specs_conflict_resolution.md).
Document History can reconstruct an available historical revision from its
@@ -0,0 +1,318 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Optional-file synchronisation architecture
## Purpose and scope
This document describes the implemented ownership and composition of
Customisation Sync and Hidden File Sync. These features share host event
boundaries, but retain separate persisted data, state, and application
behaviour. The corresponding decision history and migration constraints are
recorded in the
[Customisation Sync and Hidden File Sync ownership ADR](../adr/2026_09_customisation_and_hidden_file_sync_ownership.md).
The implementation is private application architecture. It does not define a
third-party extension API, a runtime feature registry, or a generic hidden-file
engine.
## Topology
```text
Obsidian composition (`main.ts`)
|
+--> `useOptionalFileSync`
| |
| +--> one Service-handler registration owner
| +--> pure local-path and document routing policy
| |
| +--> `CustomisationSyncContext`
| | +-- `CustomisationSyncPathOperations`
| | +-- `SnapshotPersistence`
| | +-- `SnapshotOperations`
| | +-- `ApplicationOperations`
| | +-- `ScanOperations`
| | +-- recent-event deduplicator
| | +-- immutable service-handler and testing views
| | |
| | +--> `CatalogueOperations`
| | +-- `CatalogueState`
| | +-- one catalogue queue and progress lifecycle
| | +-- `CatalogueV1`
| | +-- `CatalogueV2`
| | +-- `CatalogueMigration`
| | ^
| | +-- narrow dependencies from
| | `customisationSyncObsidianAdapter`
| |
| +--> `HiddenFileSyncContext`
| ^
| +-- narrow dependencies from
| `hiddenFileSyncObsidianAdapter`
| |
| +--> `HiddenFileSyncProcessedState`
| | +-- three autosaved reconciliation maps
| | +-- key, mtime, reset, and settlement rules
| |
| +--> `HiddenFileSyncChangeProcessor`
| | +-- storage and database change processing
| | +-- per-path serialisation and activity state
| |
| +--> `HiddenFileSyncConflictResolution`
| | +-- pending paths and two-stage conflict queue
| | +-- automatic and interactive JSON resolution
| |
| +--> `HiddenFileSyncPathAdmission`
| | +-- ownership, path, pattern, and ignore-file admission
| | +-- per-context parsed-pattern cache
| |
| +--> `HiddenFileSyncChangeNotifier`
| | +-- changed-folder batching and scheduled delivery
| | +-- suppression and Notice-effect teardown
| |
| +--> `Reconciliation`
| | +-- storage and database scans
| | +-- offline reconciliation, rebuilds, and initialisation
| |
| +-- immutable service-handler, command, repair, and testing views
|
+--> `useCustomisationSyncUI`
| +-- catalogue and operation view
| +-- Hidden File Sync initialisation view
|
+--> `useHiddenFileSyncCommands`
+-- Hidden File Sync command view
Hatch settings consumer
+-- Hidden File Sync exact-revision repair view
```
`useOptionalFileSync` is the only owner of the overlapping synchronisation
registrations. The presentation serviceFeatures receive focused views from
that composition; they do not locate either concrete context through the
application core.
## Ownership
| Owner | Owns | Does not own |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useOptionalFileSync` | Construction of both contexts, handler registration and removal, local-owner selection, namespace dispatch, compatibility callback order, and context disposal order. | Persisted feature state, synchronisation algorithms, commands, dialogues, or Notices. |
| `CustomisationSyncContext` | Lifecycle and view composition, raw-event admission and scheduling, configuration transitions, periodic scan policy, and focused-owner lifetimes. | Scan reconciliation, snapshot writes or refresh sequencing, catalogue internals, application and comparison algorithms, Obsidian dialogues, ribbon actions, or handler registration. |
| `CustomisationSyncPathOperations` | Binding live configuration-directory, mode, and device-name projections to the pure category, target-path, V1 key, V2 key, and device-prefix functions. | I/O, mutable state, local-owner selection, or persistence. |
| `SnapshotPersistence` | V1 grouped and V2 per-file local-to-database writes, unchanged-content checks, logical deletion, and explicit catalogue-refresh outcomes. | Catalogue state or refresh execution, scans, lifecycle policy, application, dialogues, or plug-in reload. |
| `SnapshotOperations` | Applying persistence outcomes to the catalogue with the inherited awaited V1 and fire-and-forget V2 refresh timing. | Snapshot encoding, catalogue state, scans, lifecycle policy, or host effects. |
| `ApplicationOperations` | Comparing, applying, duplicating, and deleting selected Customisation Sync snapshots through narrow storage, snapshot, and catalogue ports. | Catalogue enumeration, raw-event admission, periodic scheduling, or view composition. |
| `ScanOperations` | Configuration-file enumeration and V1/V2 reconciliation with local and database state. | Periodic scheduling, raw-event admission, snapshot persistence details, or catalogue state. |
| `CatalogueOperations` | Catalogue enumeration and publication, one format-dispatching queue and its progress lifecycle, and composition of the state, V1, V2, and migration modules. | Local-file scanning, snapshot application, raw-event routing, dialogues, or handler registration. |
| `CatalogueState` | Transient catalogue rows, manifest lookup and mtime cache, reactive catalogue and manifest stores, and catalogue update progress. | Database or storage I/O, scan scheduling, routing, or persistence. |
| `CatalogueV1` | Loading and publishing grouped V1 catalogue rows. | V2 decoding, migration, queue lifetime, or local-file scanning. |
| `CatalogueV2` | Building and updating per-file V2 rows and manifests. | V1 loading, migration, queue lifetime, or local-file scanning. |
| `CatalogueMigration` | Translating a grouped V1 binder into V2 per-file documents, deleting the migrated binder, and applying its required V1 refresh. | General catalogue enumeration, queue ownership, or local-file scanning. |
| Customisation Sync event deduplicator | The bounded, newest-first keys used to admit raw configuration events once. | Scheduling, path ownership, catalogue state, or persisted data. |
| `HiddenFileSyncContext` | Lifecycle and view composition, handler admission, configuration transitions, periodic scan state, exact-revision repair composition, and focused-owner lifetimes. | Scan and rebuild algorithms, path admission state, change-event serialisation, processed-state representation, notification batching, conflict queue state, Obsidian dialogues, or service handler registration. |
| `HiddenFileSyncProcessedState` | Three device-local autosaved maps, exact storage and database keys, retained known mtime, reset behaviour, and storage/database settlement effects. | File transfer, scan scheduling, conflict handling, or presentation. |
| `HiddenFileSyncChangeProcessor` | Storage and database change processing, same-path event serialisation, bounded concurrency, activity counts, and the inherited event-consumption and settlement order. | Full scans, initialisation policy, notification presentation, or conflict interaction. |
| `Reconciliation` | Storage and database enumeration, full scans, offline reconciliation, rebuild direction and ordering, processed-state adoption, initialisation sequencing, and the scoped rebuild interceptor. | Individual transfer implementation, conflict interaction, path-pattern state, periodic scheduling, or host lifecycle. |
| `HiddenFileSyncConflictResolution` | Conflict admission and deduplication, pending paths, the parallel classification and serial interaction queues, automatic merge, newer-revision policy, interactive JSON resolution, and conflict settlement. | Obsidian dialogue instances, general Hidden File Sync scans, processed-state caches, or Service handler registration. |
| `HiddenFileSyncPathAdmission` | The ownership-first eligibility sequence, hidden-path and pattern policy, asynchronous ignore-file check, and the per-context parsed-pattern cache. | Composition-level owner selection, scans, transfer, or persistence. |
| `HiddenFileSyncChangeNotifier` | Changed-folder deduplication, delayed delivery, live suppression and configuration-directory checks, scheduled-task cancellation, and the host Notice show/hide effects. | The Obsidian Notice instance, file extraction, or scan policy. |
| Customisation Sync Obsidian adapter | Obsidian conflict selection, Notice presentation, plug-in reload, restart requests, Vault enumeration, progress telemetry, and platform-derived fallback device names. | Catalogue state, routing, or persisted document operations. |
| Hidden File Sync Obsidian adapter | JSON conflict dialogue lifetime, progress presentation, grouped change Notices, plug-in reload actions, restart scheduling, Vault enumeration, and compatibility activity publication. | Transfer, reconciliation, processed-state, or conflict decisions. |
| `useCustomisationSyncUI` | Command, ribbon, dialogue, open-request subscription, and their unload teardown. | Synchronisation state or Hidden File Sync initialisation behaviour. |
| `useHiddenFileSyncCommands` | Hidden File Sync command registration, setting-change subscription, and their unload teardown. | Synchronisation state or command implementation. |
The two domain contexts coordinate one cohesive synchronisation workflow each.
Their private operations and focused owners are not additional serviceFeatures:
they do not independently register host integration or have separate
application lifetimes. The newly extracted catalogue, snapshot, application,
scan, and reconciliation modules omit the domain prefix because their feature
directories already supply that scope; public context and view names retain it
for compatibility. `CustomisationSyncPathOperations` is a stateless capability
which binds live inputs to pure path functions.
`SnapshotPersistence` is a host-neutral operation boundary: it returns
structured mutation and refresh outcomes without reaching into the catalogue
or presentation. `SnapshotOperations` consumes those outcomes and preserves
their refresh timing. `ApplicationOperations` and `ScanOperations` depend on
those narrow ports instead of calling back through the context.
`CatalogueOperations` owns one catalogue queue, its progress subscription, and
the shared state projected by the format-specific modules. The live V2 setting
selects V1 loading or V1-to-V2 migration when each queued item starts. Persisted
V1 and V2 documents can coexist during migration, so V2 documents remain
recognisable regardless of the currently selected write format. `CatalogueV1`
and `CatalogueV2` contain only their format-specific catalogue behaviour, while
`CatalogueMigration` remains the explicit bridge between them. The Hidden File
Sync path-admission owner holds the parsed-pattern cache, and its change
notifier holds the pending folder set and scheduled-task lifetime.
`HiddenFileSyncChangeProcessor` is a focused resource owner because its
semaphore, per-path serialisation, activity counters, and event settlement form
one independently testable lifecycle.
`HiddenFileSyncConflictResolution` is another focused resource owner because
conflict admission, pending-path identity, two serialisation stages, and
disposal form a separate lifecycle.
`Reconciliation` keeps storage and database enumeration together because
offline comparison, rebuilds, and each initialisation direction depend on both
sides and their ordered processed-state adoption.
The two state owners intentionally do not implement a common generic state
contract. Customisation Sync projects transient catalogue and presentation
state from `ix:` documents. Hidden File Sync persists operational markers used
for reconciliation, with distinct path identity, deletion, reset, and retained
mtime rules. Their common boundary is lifecycle ownership by a context, rather
than interchangeable state semantics.
## Routing and handler contracts
Local file ownership is selected before either context processes an event.
`optionalFileSyncRouting.ts` combines the configuration directory, feature
enablement, Customisation Sync mode, and current path category. Hidden File
Sync path admission then checks ownership again before reading its current
target patterns, ignore patterns, or ignore-file result. This keeps the same
guard available to raw events, scheduled scans, and database reflection
without duplicating the policy or its cache in the context.
The maintained local ownership is:
| Path mode | Owner |
| ------------------------------------------------------------------- | ------------------ |
| Customisation path in Selective or Flagged Selective mode | Customisation Sync |
| Customisation path in Automatic mode, with Hidden File Sync enabled | Hidden File Sync |
| Customisation path in Ignore mode | Neither context |
| Other eligible hidden path | Hidden File Sync |
| Disabled, excluded, ignored, or ordinary Vault path | Neither context |
Local ownership is distinct from persisted-document recognition. The
composition dispatches `ps:` and `ix:` conflict documents to Customisation
Sync and sends `i:` conflict documents through the Hidden File Sync semantic
handler view. Existing documents remain recognisable after a local mode
changes.
Each context exposes an immutable semantic handler view. The composition
registers these operations and adapts them to the existing Commonlib handler
contracts; registry aggregation names and binding concerns do not leak back
into either context:
- raw optional-file events are offered to exactly one selected owner;
- a selected handler which skips or fails does not fall through to the other
context;
- Customisation Sync receives virtual Customisation documents;
- Hidden File Sync receives optional synchronisation results for `i:`
documents;
- shared lifecycle handlers retain Customisation-before-Hidden order; and
- the Vault extra-target handler returns the final routed ownership decision.
## Domain dependency boundaries
Neither context imports `main.ts`, accepts `LiveSyncCore`, extends
`LiveSyncContext`, or reaches through `this.core`, `this.services`, or
`this.app`.
Each context receives live getter projections for settings and the local
database. This is important because both can be replaced during settings or
database lifecycle work. Stable ServiceModules, such as storage access and
database file access, are supplied as focused method projections. Host effects
are named individually in the dependency contract.
The Hidden File Sync exact-revision boundary uses only
`fetchEntryMeta`, `getConflictedRevs`, `fetchEntryFromMeta`, and
`storeWithBaseRevision`. A selected revision is checked against the current
winner and conflict leaves before it can be applied or extended. The repair
view therefore does not expose the complete database file-access module.
Obsidian-specific adapters can import the application core as a type and read
the required Services and ServiceModules at composition. The resulting
dependency objects are the only bridge from either domain context to Obsidian
presentation and host lifecycle APIs.
## Views and consumers
One context can back several focused views without creating several state
owners:
- `CustomisationSyncDialogView` supplies catalogue and explicit application
operations to the Customisation Sync dialogue;
- `HiddenFileSyncInitialisationView` supplies the initialisation directions
needed by that dialogue;
- `HiddenFileSyncCommandView` supplies availability and scan operations to
host-owned commands; and
- `HiddenFileSyncRepairView` supplies local inspection and exact-revision
operations to the Hatch pane.
Production consumers receive these views directly. `OptionalFileSyncFeature`
also exposes immutable, explicitly internal testing views for maintained
real-Obsidian contract tests. They provide named operations, including a scoped
rebuild interceptor, without exposing context instances, dependency objects,
queues, or writable stores. These test seams are not production service
locators and should not be used by application features. Path categorisation
and key derivation are tested directly through their focused capability rather
than being re-exported through a broad context testing view.
## Lifecycle and disposal
The composition is created after the Service Hub and required ServiceModules
exist, and before lifecycle-driven feature work begins. Each context creates
its own periodic processor and focused resource owners.
`CustomisationSyncContext` creates one path capability, one snapshot-persistence
boundary, one snapshot coordinator, one application owner, one scan owner, one
catalogue owner, and one recent-event deduplicator. The catalogue owner creates
its shared state, one queue and progress subscription, and the V1, V2, and
migration modules. `HiddenFileSyncContext` creates one
path-admission owner, one change notifier, and one processed-state owner before
composing database write and extraction operations around their narrow ports.
It then creates one change processor, one conflict-resolution owner, and one
reconciliation owner. The change processor owns its semaphore and activity
state; the reconciliation owner owns its scoped testing interceptor.
Full conflict scans admit discovered paths into the same queue without
suspending it, so ordinary database conflict notifications continue during a
slow scan. After enumeration, the operation waits for both classification and
interaction stages to drain.
On application unload, `useOptionalFileSync` first removes every Service
handler registration. It then disposes Customisation Sync followed by Hidden
File Sync, preserving the former compatibility order. Disposal disables
periodic admission, disposes the conflict-resolution owner, terminates queues,
clears transient caches and pending sets, cancels scheduled notification work,
resets compatibility telemetry, and hides owned Notices. The two presentation
serviceFeatures independently remove their commands, event subscriptions,
ribbon state, and dialogue instances.
## Persisted compatibility
This architecture does not migrate persisted data:
- Customisation Sync remains in the `ix:` namespace and continues to read V1
grouped and V2 per-file data;
- Hidden File Sync remains in the `i:` namespace;
- file content remains in Chunks referenced by Metadata rather than being
embedded as ordinary Metadata content;
- exact PouchDB revision identifiers remain part of repair and conflict
operations; and
- Hidden File Sync processed-state keys remain device-local key-value data.
Any change to these contracts requires separate compatibility tests and, when
appropriate, a migration decision.
## Verification boundaries
Focused unit tests cover routing, path-option binding, Hidden File Sync
admission ordering and cache invalidation, semantic handler views, context
owner isolation, teardown, initial cache selection, exact-revision repair,
Customisation Sync scan reconciliation and context delegation, single-queue
catalogue disposal and publication, live V1/V2 dispatch, V1 and V2 snapshot
persistence outcomes, logical-deletion idempotence, refresh ordering,
application operations, Hidden File Sync change-event serialisation and
settlement, reconciliation direction and scan ordering, notification batching,
conflict queue admission, revision selection, automatic and interactive merge
effect ordering, conflict dialogue adaptation, grouped Notices, and
compatibility activity publication.
The boundary test prevents either domain context from regaining core or
Obsidian dependencies.
Changes to local routing or transfer require the maintained Customisation Sync
and Hidden File Sync real-Obsidian workflows. A composition change also
requires the mixed-ownership case which proves that one local path is not
written by both contexts.
+333
View File
@@ -0,0 +1,333 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
commonlib-source-commit: e770f617ff0fc88f4823226b0ab3aefdff50cc1e
status: accepted
---
# Replicator architecture
This is the implemented architecture for Self-hosted LiveSync 1.0.24 with `@vrtmrz/livesync-commonlib` 0.1.21. It is an implementation overview for developers maintaining the composition or adding a built-in provider. The corresponding Commonlib source was inspected at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`.
## Status, scope, and source-of-truth boundary
The plug-in repository is the source of truth for host composition, host scheduling, Obsidian/CLI/WebApp/WebPeer integration, provider declarations, and host-owned resource adapters. Commonlib is the source of truth for the provider contract, the active-publication state machine, typed replication runners, P2P service ownership, and Journal transport primitives. This repository consumes Commonlib as the published `0.1.21` package; it does not maintain a source mirror or a generated fallback.
The implementation has three built-in providers:
- CouchDB, composed by this repository;
- Object Storage, composed by this repository; and
- P2P, composed by Commonlib's `useP2PReplicatorFeature` in each application runtime which supports it.
The catalogue is closed at host composition. `src/common/replicatorProviders.ts` exhaustively composes the central providers, while the Commonlib P2P feature composes its P2P provider. This is not a runtime third-party registry: a provider cannot be added by registering a name, loading a plug-in, or supplying a setting at runtime. Adding a provider means changing the relevant Commonlib and host composition, then shipping and testing that composition.
The three capability ADRs record the decisions which led to this shape. They are useful decision history, but this document describes the current implementation and its ownership boundaries rather than repeating the ADR sequence.
## Terminology
The [Project glossary](../glossary.md#developer-and-design-terms) is canonical
for project-specific vocabulary in this document. In particular, see
[Replicator](../glossary.md#replicator),
[Replicator provider definition](../glossary.md#replicator-provider-definition),
[Active publication](../glossary.md#active-publication),
[Admission and reservation](../glossary.md#admission-and-reservation),
[Configuration identity](../glossary.md#configuration-identity),
[Capability](../glossary.md#capability),
[Central remote](../glossary.md#central-remote),
[Publication retirement](../glossary.md#publication-retirement), and
[Fence, generation, and epoch](../glossary.md#fence-generation-and-epoch).
The glossary also fixes the ownership meanings of
[Adjunct P2P transport](../glossary.md#adjunct-p2p-transport),
[Remote resource and probe](../glossary.md#remote-resource-and-probe),
[Non-owning adapter](../glossary.md#non-owning-adapter),
[P2P service, room session, and demand](../glossary.md#p2p-service-room-session-and-demand),
[Interaction authority](../glossary.md#interaction-authority),
[Journal remote epoch](../glossary.md#journal-remote-epoch),
[Replication outcome](../glossary.md#replication-outcome), and
[Suspension](../glossary.md#suspension).
The user-facing glossary also distinguishes
[OneShot Sync from Continuous replication](../glossary.md#user-facing-and-operational-terms).
Names shown in code font are source identifiers, not additional prose terms.
## Ownership and topology
The portable topology below names ownership rather than merely call order. An arrow means that the object or layer composes, invokes, or owns the next item.
```text
Application composition
Obsidian main / CLI / WebApp --> LiveSyncBaseCore --+
WebPeerRuntime ------------------------------------+--> Service Hub
|
+----------------------------------------+---------------------+
| |
v v
ReplicatorService (Commonlib) ReplicationService (Commonlib)
| |
+--> closed provider catalogue +--> typed replication runners
| CouchDB / Object Storage / P2P | readiness + outcomes
| |
+--> active publication <------ exact-publication admission ---+
provider + instance + identity
useP2PReplicatorFeature (Commonlib)
|
+--> registers the P2P provider, whose factory creates non-owning active adapters
|
+--> stable P2P service views and lifecycle
|
+--> P2PRoomSessionOwner
|
+--> P2PAutomationCoordinator
+--> current P2PRoomSession
|
+--> P2PHost / TrysteroReplicator
|
+--> Trystero room, relays, and physical peers
```
`LiveSyncBaseCore` receives a Service Hub, registers the central provider definitions, composes `serviceFeature` functions, and retains only focused views. `WebPeerRuntime` composes directly over its browser Service Hub because it is a P2P-only host. The P2P feature is composed by each supporting application runtime, so the CLI and web applications can use the same transport ownership without making the active adapter a second owner.
| Owner | Responsibility | Explicitly does not own |
| --- | --- | --- |
| Host composition | Selects the closed provider catalogue, supplies host adapters, and registers features before lifecycle work begins. | Active-instance retirement or operation admission. |
| Commonlib `ReplicatorService` | Provider registration, active publication, exact-publication reservations, serial lifecycle transitions, transfer stop during suspension or retirement, and physical close. | Replication readiness, trigger policy, or P2P room ownership. |
| Commonlib `ReplicationService` and its typed coordinator | User and unattended OneShot dispatch, Continuous startup, readiness, interaction authority, finite-activity accounting, outcomes, and failure hand-off. | Active publication replacement or scheduling policy. |
| Host replication scheduling `serviceFeature` | Decides when resume, Periodic, and Continuous requests may run, and fences stale scheduled work. | Provider construction, transfer mechanics, or active Replicator close. |
| Provider definition and runner | Declares what one remote kind supports and adapts its transfer results to typed outcomes. | Selecting when a request should run. |
| Stable P2P service and room-session owner | P2P demand, binding reconciliation, session retirement, finite room operations, automation state, and focused views. | The active P2P adapter's publication lifetime or Trystero's physical peers. |
## Request flow
1. Composition registers the central provider definitions before lifecycle-driven provider initialisation. P2P composition registers its own Commonlib provider and returns stable P2P views and lifecycle controls.
2. `ReplicatorService` serialises setting realisation, active-provider initialisation, database lifecycle transitions, suspension, and unload. `ReplicationService` owns per-request readiness. Resume work is separately owned by the host scheduling feature and the P2P service lifecycle.
3. `ReplicatorService` resolves the current `remoteType`, checks `isConfigured`, and computes the provider's opaque configuration identity. If the provider and identity are unchanged, the active publication is retained. If either changes, the replacement fence runs before a new publication is created.
4. A typed operation such as `runUserInitiated`, `runUnattended`, or `startContinuous` acquires the current publication after earlier queued lifecycle transitions have settled. It checks interaction authority, capability support, and readiness in the order required by the request type, then takes an immutable settings snapshot.
5. The operation admits a reservation against the exact publication and identity. The provider-specific runner owns the transfer; finite operations are counted as bounded remote activity, while continuous replication is not.
6. Provider resources are created through the declared resource factories when a feature needs a connection, preferred-tweak, Security Seed, or synchronisation-information probe. Resource ownership is explicit, and owned resources are disposed in the caller's `finally` path.
7. The runner returns a `ReplicationOutcome` rather than using `undefined` as a success signal. Documents delivered through `parseSynchroniseResult` are queued by the host result processor for local application; central compatibility recovery and Security Seed preflight remain host features around the typed operation.
8. Automatic `database-event`, `editor-save`, `file-open`, `merge`, `resume`, `periodic`, and `daemon` requests select unattended authority explicitly. Unattended work cannot prompt, select a peer interactively, or silently fall back to a legacy capability.
The active publication may change while a request is being prepared. The reservation keeps the admitted old instance alive until the operation settles; a new request admitted after the queued transition observes the new publication. A callback which holds a reservation must not await a lifecycle transition which itself waits for that reservation.
## Provider contract and current capability matrix
The Commonlib contract is deliberately small. A provider definition supplies the following shape (with the exact generic types omitted here for readability):
```typescript
{
kind,
diagnosticName,
readiness,
isConfigured(setting),
configurationIdentity(setting),
create(setting),
remoteResources,
centralRemoteAdministration?,
userInitiatedOneShot,
unattendedOneShot,
continuous,
stopActiveTransfer
}
```
`defineReplicatorProviderDefinitions` makes the definition map exhaustive for the selected `RemoteType` tuple and rejects duplicate, missing, extra, or mismatched runtime definitions. Capability declarations are explicit. `supported` adapts a provider runner to `ReplicationOutcome`; `not-implemented` and `not-applicable` produce typed blocked outcomes. The contract distinguishes user authority from `NO_INTERACTION`, so a provider cannot accidentally prompt from an unattended trigger.
The factory receives the fully merged effective settings. It returns a `ReplicatorInstance` with only four required lifecycle methods:
| Method | Contract |
| --- | --- |
| `initializeDatabaseForReplication()` | Prepare local state before publication. `false` rejects and disposes the candidate. |
| `openReplication(setting, keepAlive, showResult, ignoreCleanLock)` | Retained compatibility entry point for finite or Continuous work. A typed provider runner must convert its `void` or Boolean settlement to an explicit outcome; `void` is not finite success. |
| `terminateSync()` | Request cancellation of active transfer work and settle synchronously or asynchronously. It does not transfer ownership or replace physical close. |
| `closeReplication()` | Release resources owned by this instance. It runs only after admitted work drains; a non-owning adapter, such as P2P, must leave service-owned resources alone. |
Provider-specific methods remain on provider-specific interfaces or host-owned adapters. They are not added to the generic contract merely because an old compatibility class exposed them.
### Current capability matrix
| Capability | CouchDB | Object Storage | P2P |
| --- | --- | --- | --- |
| Readiness | Central remote preparation required | Central remote preparation required | Central preparation not applicable; peer readiness is provider-owned |
| Active Replicator factory | `LiveSyncCouchDBReplicator` | `LiveSyncJournalReplicator` | `P2PActiveReplicatorAdapter` over the stable P2P service |
| User-initiated OneShot | Supported | Supported | Supported, with explicit peer selection |
| Unattended OneShot | Supported | Supported | Supported for configured targets; no peer-selection prompt |
| Continuous replication | Supported | Not applicable | Not applicable |
| Stop active transfer | Supported | Supported | Supported; cancels finite operations while retaining the room when appropriate |
| Connection resource | Supported | Supported | Not applicable |
| Preferred-tweak resource | Supported | Supported | Not applicable |
| Security Seed resource | Supported | Supported | Not applicable |
| Synchronisation-information resource | Supported | Not applicable | Not applicable |
| Central remote administration | CouchDB administration supported | Object Storage administration supported | Not applicable |
The matrix is the current host composition, not a promise that every provider must support every row. A new provider must declare every resource kind and every operation capability, using `not-applicable` where the concept does not exist. Central remote administration is a cohesive capability: it covers the applicable verification milestone and mark-resolved, lock, and unlock mutations rather than exposing individual legacy helpers as generic operations.
## Active Replicator lifecycle and exact replacement fence
Commonlib's `ReplicatorService` serialises lifecycle transitions on one queue. It owns the active publication, reservations against that publication, transfer stop, and final close. The effective configuration identity is deliberately opaque; comparing it is valid, but inspecting or persisting it is not.
```text
absent
|
| configured lifecycle initialisation
v
candidate (private and not admitted)
| \
| initialised and current \ failed or stale --> closed; no active publication
v
active publication
| \ same provider and identity --> retained
| \ suspension ----------------> transfer stopped; publication retained
|
| provider, identity, database, or terminal lifecycle change
v
quiescing (removed from current; new admission fenced)
|
| stop --> drain exact reservations --> close --> complete retirement
v
absent --> optional replacement candidate
```
For a provider or effective-configuration change, the exact fence is:
1. Enqueue the transition on the serial lifecycle queue.
2. Read the current setting, resolve the closed provider definition, check `isConfigured`, and compute the effective identity.
3. If the current publication has a different provider or identity, call `beginRetirement`. This stops new reservations and removes the publication from the current slot.
4. Request the provider's `stopActiveTransfer` capability. The legacy `terminateSync` path is retained only for an untyped compatibility publication.
5. Await settlement of reservations admitted from the retiring publication. New operations cannot enter it.
6. Call the old instance's `closeReplication`.
7. Mark the retirement complete. No publication is installed between removal of the old publication and this completion.
8. Create a candidate through the provider definition, reset provider statistics, and yield the required microtask boundary.
9. Initialise the candidate for the current database and run `onBeforeReplicatorPublication` handlers.
10. Re-read the current setting, provider, and opaque identity. If any is stale, dispose the candidate and publish nothing; the queued lifecycle work will resolve the newer state.
11. Publish the provider, candidate instance, and identity atomically as the new active publication.
The same provider and identity retain the current publication. Database initialisation is a lifecycle boundary even when the setting identity is unchanged: the old publication is retired before the physical local database is replaced, then a candidate is created for the new database. A failed or stale candidate leaves the service without an active publication; it is not silently substituted with a previous instance.
If transfer stop fails, retirement still proceeds to draining and physical close. If physical close rejects, retirement remains fenced and no replacement can be published; a later serial transition may retry the same retirement. The service never restores admission to a publication once retirement has begun.
## Generation and epoch fences
These values protect different state machines. They must not be collapsed into one general-purpose generation.
| Fence | Owner and representation | Changes when | Protects | Does not protect |
| --- | --- | --- | --- | --- |
| Active publication identity | Commonlib `ActiveReplicatorPublication` object, containing provider, instance, and opaque configuration identity; not a numeric counter | Provider/configuration replacement, or a database lifecycle replacement | Admission and completion against the exact active instance | Delayed scheduling, P2P automation baselines, or Journal remote-wipe decisions |
| Host scheduling lifecycle generation | `ReplicationSchedulingContext.lifecycleGeneration` | Scheduling resumes after the lifecycle was disabled | Resume operations and timer callbacks from a previous host scheduling lifecycle | Provider replacement, P2P room callbacks, or Journal transfers |
| P2P service lifecycle generation | Private `P2PServiceState.lifecycleGeneration` | Explicit disconnect or host lifecycle closure | Delayed P2P AutoStart and service-level automatic demand | A room's operation set, automation baseline, or remote Journal epoch |
| P2P session object / epoch fence | The current `P2PRoomSession` object, its `acceptingOperations` flag, session abort signal, and the owner's lifecycle queue; there is no exported numeric P2P session epoch | Room binding replacement, retirement, or owner close | Room callbacks, finite operations, peer handlers, and stale candidate sessions | Cross-session automation deduplication and host scheduling |
| P2P automation generation | `P2PAutomationCoordinator.generation` | `beginLifecycle` or effective identity reconciliation (namespace or database object) | Completed peer baseline publication and stale automation completions | Room ownership, explicit disconnect veto, and physical peer connection ownership |
| Journal stop generation | `LiveSyncJournalReplicator.journalTransferStopGeneration` | `terminateSync` requests a stop | Admitted Journal transfers after setup and before `client.sync`; repeated stops share settlement | Provider publication identity and remote checkpoint/cache identity |
| Journal remote epoch | `CheckPointInfo.journalEpoch`, derived as `protocolVersion:pbkdf2salt` | Successfully read sync parameters yield a different value; a subsequent history probe decides whether checkpoint caches must be reset | Journal checkpoint and deduplication-cache reconciliation across remote histories | Local cancellation, provider retirement, or transfer admission |
In particular, a numeric value in one row cannot be used as evidence that an operation in another row is current. Failure to read Journal sync parameters does not produce a new remote epoch, and a P2P transport replacement does not by itself clear the automation coordinator's completed-peer baseline.
## Suspension, terminal retirement, and database replacement
| Event | `ReplicatorService` publication | P2P service and adapter | Result |
| --- | --- | --- | --- |
| Application suspension | Requests `stopActiveTransfer` and retains the active publication | `closeForLifecycle` closes the current room/session and invalidates delayed automation; the active adapter is non-owning and does not close the service through `closeReplication` | Suspension is reversible. Resumption schedules the appropriate P2P AutoStart and host replication work. |
| Provider setting or effective identity change | Runs the complete replacement fence | Reconciles or replaces the P2P room when its effective binding changes | The old publication/session cannot receive new work. |
| Database replacement or rebuild | Retires and closes the active publication before physical database teardown; database-ready events permit reinitialisation | Closes the P2P room before database destruction and creates a binding for the new database object | No provider or room may retain the old database. |
| Unload or terminal lifecycle close | Stops, drains, closes, and completes retirement; no active publication remains | `closeForLifecycle` clears owner demand, closes the current room, and invalidates automation | Terminal retirement is not resumed. |
Suspension and retirement therefore have different guarantees. `ReplicatorService` suspension stops transfer but intentionally retains the provider instance and publication. Terminal retirement removes admission, drains it, closes it, and does not publish a replacement unless a later lifecycle event explicitly initialises one. P2P transport is additionally closed on suspension because its service lifecycle owns a room session, but the active P2P adapter is only a compatibility handle and does not own that session.
## Owned resources and probes
| Resource or probe | Owner | Lifetime and disposal rule |
| --- | --- | --- |
| Active Replicator publication | Commonlib `ReplicatorService` | Publication retirement fences admission, drains reservations, calls `closeReplication`, and completes the retirement. |
| Central connection probe | Host resource factory in `src/common/replicatorResources/connection.ts` | A caller-owned CouchDB or Object Storage snapshot backed by a concrete Replicator/connection; dispose it in `finally`. It does not replace the active publication. |
| Preferred-tweak probe | Host `preferredTweak` resource factory | Read through the declared resource, then dispose the owned resource. |
| Security Seed probe | Host `securitySeed` resource factory and the replication preflight | Use `createRemoteResource` and `withOwnedRemoteResource`; reject an empty seed and always dispose the resource. It does not assume that an active Replicator exists. |
| Synchronisation-information probe | Host `synchronisationInformation` resource factory | Check or read through the resource and dispose it; an unavailable remote is not treated as a confirmed absence. |
| Central administration operation | Provider administration runner | Uses its declared verification and mutation ownership. A fresh connection may be owned by the operation; an active Journal client borrowed from the active provider is not disposed by the borrower. |
| Journal client and transfer set | `LiveSyncJournalReplicator` | The Journal Replicator owns the client and active transfer promises. `terminateSync` requests stop and awaits the shared settlement; `closeReplication` disposes the client without lazily creating one. |
| P2P room/session, finite operation set, and relay actions | `P2PRoomSessionOwner` and current `P2PRoomSession` | The owner serialises binding and demand changes. Session retirement rejects admission, aborts and awaits operations, disables broadcast, and disposes the session Replicator. |
| Physical Trystero peer connections | Trystero runtime | Commonlib must not close raw `room.getPeers()` connections merely because a logical room session retires; shared Trystero ownership may outlive an idle room callback. |
| Physical local database | Commonlib DatabaseService | Database lifecycle owns teardown and readiness. Replicator and P2P owners close before the database is destroyed. |
Probe callers must use the resource capability rather than reaching through `LiveSyncBaseCore.replicator`. This keeps a short-lived observation from acquiring ownership of the active transfer or publication.
## P2P special ownership and the non-owning adapter
P2P is composed as a `serviceFeature` and has more state than a central provider. It can remain enabled as an adjunct while CouchDB or Object Storage is the selected main remote; `ReplicatorService` publishes the P2P active adapter only when `remoteType` is P2P. The active-publication owner and the room-session owner are therefore deliberately independent.
The stable service owns persistent demand (`explicit`, `automatic`, or `rebuild-continuation`), finite-operation demand, the lifecycle queue, the effective binding, and the current room session. The room owner compares the local database object separately and includes the effective device name in its binding signature, so the binding is not interchangeable with the P2P provider's active-publication configuration identity. The automation coordinator owns automation-baseline deduplication. The current `P2PRoomSession` owns one room, peer handlers, advertisements, RPC, session cancellation, and finite operations. Trystero owns shared relay clients and physical peer connections.
`P2PActiveReplicatorAdapter` implements the minimal `ReplicatorInstance` view required by the provider contract. Its `initializeDatabaseForReplication`, `openReplication`, and `terminateSync` methods delegate to the P2P service. Its `closeReplication` is intentionally a no-op: closing the active adapter must not close the room, relay actions, finite-operation registry, or stable P2P service. The service lifecycle (`closeForLifecycle`, owner close, or binding reconciliation) is the only owner which retires the room.
The P2P connection probe follows the same boundary. An active compatible room may be observed; an incompatible active binding blocks the probe. An idle probe can run a caller-owned trial through the owner queue and must await clean-up. It must not publish itself as the active room or close resources owned by another session.
Automatic configured-target replication acquires finite room demand, waits for peer advertisements within a bounded window, evaluates admission without prompting, shares the baseline through `P2PAutomationCoordinator`, and returns an explicit completed, partial, blocked, cancelled, or failed outcome. Explicit disconnect veto remains distinct from host lifecycle closure. AutoStart cannot clear an explicit disconnect veto; rebuild continuation is a separately authorised path.
## Adding a built-in provider
Provider work crosses the Commonlib package boundary and this repository's host composition. The following sequence is the smallest complete path; omit a step only when the provider genuinely has no corresponding concept.
### First decide whether this is a provider
A new provider is appropriate when a remote kind needs a distinct active Replicator lifecycle, effective configuration identity, readiness policy, or replication roles. A new S3-compatible service or another backend which retains the Object Storage Journal protocol is usually an `IJournalStorage` adapter instead; see [Journal Replicator 2nd Edition](../design_docs_of_journalsync_2nd.md). A new read-only observation over an existing provider is usually a remote resource or a focused view. Neither case needs another active provider.
1. **Define the canonical remote kind in Commonlib.** Add the `RemoteType` value and its setting type in `src/common/models/setting.const.ts` and `src/common/models/setting.type.ts`, update exports such as `src/common/types.ts`, and add defaults or persistence fields only where the provider needs them. Add focused setting and migration tests.
2. **Implement the Replicator in Commonlib.** Place the provider-specific Replicator and transport code under `src/replication/<provider>/`. Implement the minimal `ReplicatorInstance` contract, cancellation, and close semantics. Keep provider-specific operations on focused facets. Add unit tests for success, cancellation, failure, stop, and replacement-sensitive clean-up.
3. **Define effective configuration identity.** Include every setting which changes the live binding, and exclude profile labels or policy-only settings. Normalise two spellings only when the runtime genuinely treats them as equivalent. Put shared identity logic in Commonlib when the provider is shared there; put the host projection in `src/common/replicatorConfigurationIdentity.ts` when this repository owns it. Test that equivalent effective settings retain an instance and binding changes replace it. Never expose identity values in logs, UI, or persistence.
4. **Add configuration and setup seams in Commonlib.** If the provider has a connection string, profile, migration, or document representation, update the applicable files, including `src/common/ConnectionString.ts`, `src/remoteConfigurations.ts`, `src/common/configForDoc.ts`, `src/API/processSetting.ts`, and their focused tests. These files are conditional: do not add a setting representation which the provider does not need.
5. **Declare the provider contract surface at its composition owner.** Add a central provider to the tuple and definition map in this repository's `src/common/replicatorProviders.ts`; add a P2P-like provider to the closed tuple owned by its Commonlib `serviceFeature`. In the definition, declare readiness, all four remote-resource capability entries, user and unattended OneShot runners, Continuous where applicable, `stopActiveTransfer`, and central administration where applicable. Extend `RemoteResource` kinds only for a genuinely cross-provider resource; do not encode provider-specific helpers as generic capabilities.
6. **Implement stateful transport ownership, if required.** For a P2P-like provider, add a stable service owner, focused views, lifecycle ownership, binding identity, session retirement, and automation fences in Commonlib, then compose it through a `serviceFeature`. Keep any active adapter non-owning if the service owns a replaceable transport. Add lifecycle, stale-callback, probe, and database-replacement tests before host integration.
7. **Compose the provider in each supporting application.** Central definitions are registered by `LiveSyncBaseCore`. A dedicated stateful feature must be composed from the applicable hosts: `src/main.ts`, `src/apps/cli/main.ts`, `src/apps/webapp/WebAppRuntime.ts`, and `src/apps/webpeer/src/WebPeerRuntime.ts`. Each selected catalogue must remain exhaustive and closed; add no runtime provider registry.
8. **Add host-owned resources and administration.** Add or extend `src/common/replicatorResources/` for connection, preferred-tweak, Security Seed, and synchronisation-information probes. Add provider-specific central verification and mutations in `src/common/centralRemoteAdministration.ts` when applicable. Test ownership, snapshots, the distinction between unavailable and absent states, postconditions, and disposal.
9. **Integrate setup and user-facing configuration.** Update the relevant setup dialogue files under `src/modules/features/SetupWizard/dialogs/`, including `dialogs/setupDialogTypes.ts`, SetupManager or setup features, remote configuration handling, and message resources under `src/common/messagesYAML` plus generated baked messages where required. Follow the terminology and settings mappings in the repository documentation, and add setup, serialisation, and migration tests.
10. **Integrate host operations and triggers.** Adapt only the capability call sites which the provider supports. Check `src/serviceFeatures/replicationScheduling.ts`, `src/serviceFeatures/replication/`, CLI commands under `src/apps/cli/commands`, and application-specific lifecycle composition. Ensure unattended paths use `NO_INTERACTION`, periodic and resume fallback obey capability outcomes, and no caller reaches for `getNewReplicator` or the `LiveSyncBaseCore.replicator` compatibility getter.
11. **Validate the package boundary.** In Commonlib, run its focused unit tests, build or pack the exact candidate artefact, and test the downstream LiveSync consumer against that artefact. In this repository, run provider map, configuration-identity, resource, central-administration, scheduling, and replication-feature unit tests. Add a real remote integration test, CLI E2E coverage, or real Obsidian E2E coverage for every boundary the provider claims to support.
A provider is complete only when its source ownership, replacement fence, capability matrix, setup path, and tests agree. Updating a setting type or adding a class without adding the closed composition definition does not make it a built-in provider.
## Compatibility seams and non-goals
- `ReplicatorService.getNewReplicator`, `getActiveReplicator`, and the `LiveSyncBaseCore.replicator` getter remain compatibility seams for existing callers. Beyond `ReplicatorInstance`, `LiveSyncBaseCore.replicator` exposes provider-specific members only as an optional compatibility view. None is a new provider extension point.
- `ReplicationService.performReplication` remains a direct legacy path through the active instance. New call sites use `replicateUserInitiated`, `replicateUnattended`, `replicateUnattendedByEvent`, `startContinuous`, or `stopActiveTransfer`, as appropriate.
- `LiveSyncAbstractReplicator` and other legacy classes may retain methods needed by existing modules. New features use typed provider capabilities, resource factories, and focused service views; they do not infer capabilities from a large legacy class.
- The generic contract does not unify directional Journal operations, Streaming replication, Chunk retrieval, remote-size inspection, garbage collection, repair workflows, or provider-specific administration. Those remain explicit provider or host features.
- The provider catalogue is not a public runtime registry, dynamic plug-in API, or settings-driven discovery mechanism. Unknown `RemoteType` values are composition/configuration failures, not third-party providers which the runtime should load.
- Commonlib remains an external authoritative package. This repository must not recreate `src/lib`, `_types`, or another source mirror to bypass the package boundary.
- P2P logical room retirement does not authorise closing shared raw `RTCPeerConnection` objects. Physical transport ownership remains with Trystero.
- No numeric P2P session epoch is exported. Session object identity, admission flags, abort signals, and the owner queue provide the fence; the P2P automation generation and the host scheduling generation protect different concerns.
- Suspension is not a database replacement or a successful replication result. It stops or closes the appropriate active work and relies on the next lifecycle event to resume, reconcile, or retire it.
## Source map
### This repository
| Concern | Source and tests |
| --- | --- |
| Host composition and compatibility boundary | [`LiveSyncBaseCore.ts`](../../src/LiveSyncBaseCore.ts), [`main.ts`](../../src/main.ts), [`src/apps/cli/main.ts`](../../src/apps/cli/main.ts), [`WebAppRuntime.ts`](../../src/apps/webapp/WebAppRuntime.ts), [`WebPeerRuntime.ts`](../../src/apps/webpeer/src/WebPeerRuntime.ts) |
| Closed central provider map | [`replicatorProviders.ts`](../../src/common/replicatorProviders.ts), [`replicatorProviders.unit.spec.ts`](../../src/common/replicatorProviders.unit.spec.ts) |
| Provider identities and resources | [`replicatorConfigurationIdentity.ts`](../../src/common/replicatorConfigurationIdentity.ts), [`replicatorResources/`](../../src/common/replicatorResources/index.ts), [`replicatorResources.unit.spec.ts`](../../src/common/replicatorResources.unit.spec.ts) |
| Central administration and preflight | [`centralRemoteAdministration.ts`](../../src/common/centralRemoteAdministration.ts), [`centralRemoteAdministration.unit.spec.ts`](../../src/common/centralRemoteAdministration.unit.spec.ts), [`replication/preflight.ts`](../../src/serviceFeatures/replication/preflight.ts) |
| Host scheduling and replication feature | [`replicationScheduling.ts`](../../src/serviceFeatures/replicationScheduling.ts), [`replicationScheduling.unit.spec.ts`](../../src/serviceFeatures/replicationScheduling.unit.spec.ts), [`replication/index.ts`](../../src/serviceFeatures/replication/index.ts) |
| Service graph and bounded local activity | [`ObsidianServices.ts`](../../src/modules/services/ObsidianServices.ts), [`ObsidianServiceHub.ts`](../../src/modules/services/ObsidianServiceHub.ts) |
| Architecture guidance | [`devs.md`](../../devs.md), [Service feature and legacy Module boundaries](service_feature_and_legacy_module_boundaries.md), [Project glossary](../glossary.md), [Documentation style and vocabulary conventions](../terms.md), [`docs/settings.md`](../settings.md), [`docs/troubleshooting.md`](../troubleshooting.md) |
### Commonlib 0.1.21
The exact package tree described here is [pinned at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`](https://github.com/vrtmrz/livesync-commonlib/tree/e770f617ff0fc88f4823226b0ab3aefdff50cc1e). The source and design-document links below target that commit.
| Concern | Commonlib source or design document at the pinned commit |
| --- | --- |
| Provider contract, outcomes, identities, and resource capabilities | [`src/replication/ReplicatorInstance.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorInstance.ts), [`src/replication/ReplicatorProvider.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorProvider.ts), [`src/replication/RemoteResource.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/RemoteResource.ts), [`src/replication/CentralRemoteAdministration.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralRemoteAdministration.ts), [`src/replication/CentralCompatibility.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralCompatibility.ts) |
| Active publication and typed operations | [`src/services/base/ReplicatorService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.ts), [`activeReplicatorState.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.activeReplicatorState.ts), [`typedReplication.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.typedReplication.ts), [`readiness.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.readiness.ts) |
| P2P service, room ownership, and automation | [`P2PService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/p2p/P2PService.ts), [`P2PRoomSessionOwner.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSessionOwner.ts), [`P2PRoomSession.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSession.ts), [`useP2PReplicatorFeature.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/useP2PReplicatorFeature.ts), [`P2PAutomationCoordinator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PAutomationCoordinator.ts) |
| P2P lifecycle design | [`docs/p2p-transport-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/p2p-transport-lifecycle.md) |
| Database and service-feature lifecycle | [`docs/database-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/database-lifecycle.md), [`docs/service-feature-composition.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/service-feature-composition.md), [`docs/settings-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/settings-lifecycle.md) |
| Journal transfer and remote epoch | [`LiveSyncJournalReplicator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/LiveSyncJournalReplicator.ts), [`JournalSyncCore.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncCore.ts), [`JournalSyncTypes.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncTypes.ts) |
| Journal storage adapter boundary | [`JournalStorageAdapter.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/objectstore/JournalStorageAdapter.ts) |
### Decision records
- [Core provider contract and capabilities ADR](../adr/2026_08_replicator_capabilities_01_core_contract.md)
- [P2P service lifecycle ADR](../adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Replicator migration plan ADR](../adr/2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle ADR](../adr/2026_07_p2p_transport_lifecycle.md)
- [Bounded Remote Activity ADR](../adr/2026_07_bounded_remote_activity.md)
@@ -1,7 +1,7 @@
---
date: 2026-08-30
commonlib-version: "0.1.19"
self-hosted-livesync-version: "1.0.21"
date: 2026-09-04
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
@@ -34,8 +34,9 @@ Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain conv
3. construct and register built-in and host-supplied Modules;
4. compose the built-in Commonlib serviceFeatures;
5. compose host-supplied serviceFeatures;
6. construct add-ons; and
7. call `onBindFunction()` for each registered Module.
6. construct add-ons;
7. compose the late core serviceFeatures whose handlers must follow host features and add-ons; and
8. call `onBindFunction()` for each registered Module.
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
@@ -127,54 +128,31 @@ This split allows tests to verify:
The operation does not need an application Module identity.
### Ordered start-up composition and registration-only features
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
Command and ribbon registration are serviceFeatures for the same dependency-visibility reason, but they are not start-up migrations. The basic commands remain a host-neutral feature composed by `LiveSyncBaseCore`, while the replication ribbon remains an Obsidian-only feature composed by the Obsidian host. Both retain `onInitialise` registration so moving them out of the Module list does not make their effects run during construction.
### Private state and ordered handlers: target filters
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
### Legacy example to improve when touched: conflict checking
### Implemented composition: conflict resolution
`ModuleConflictChecker` currently combines:
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains the queue.
- conflict policy decisions;
- two `QueueProcessor` owners;
- cancellation signalling;
- access to settings and active-file state; and
- registration into the conflict Service.
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
Its queues are class fields which dereference `this.services` during field initialisation, and its public handlers are bound later in `onBindFunction()`.
Interactive resolution is a separate Obsidian-owned serviceFeature. It registers the manual conflict handler, commands, start-up scan, unresolved-message contribution, cancellation listener, and unload clean-up. Its postponed-conflict set, active dialogue, and dialogue queue are private, session-local state. Manual comparisons are shown one at a time: a request for the active file publishes `EVENT_CONFLICT_CANCELLED` to cancel and replace its dialogue, while a request for another file waits. A resolution received through replication closes an open dialogue for the resolved path through the same event, or discards its waiting request before a stale dialogue can open. On unload, the feature drops waiting requests and publishes the same event for the active path before the host event channel is retired, so the dialogue closes and its waiting operation completes. The feature receives a dialogue-opening adapter and connects to the common feature only through the conflict Service; it does not expose an Obsidian application or dialogue as a general capability.
A bounded change to this area should prefer a shape such as:
Both operation layers acquire the active local database through an operation-time accessor. Composition occurs before the database is opened, and a reset may replace the active instance, so retaining the database object at composition time would violate both start-up and reset boundaries.
```typescript
interface ConflictCheckContext {
readonly checkQueue: QueueProcessor<FilePathWithPrefix, unknown>;
readonly resolveQueue: QueueProcessor<FilePathWithPrefix, unknown>;
}
interface ConflictCheckDependencies {
readonly conflict: ConflictCapability;
readonly currentSettings: () => ConflictSettings;
readonly getActiveFilePath: () => FilePathWithPrefix | undefined;
readonly log: LogFunction;
}
function queueConflictCheck(
context: ConflictCheckContext,
dependencies: ConflictCheckDependencies,
path: FilePathWithPrefix
): Promise<void> {
// Make the decision and enqueue through explicit collaborators.
}
export function useConflictChecking(host: ConflictCheckingHost): void {
const context = createConflictCheckContext(host);
host.services.conflict.queueCheckFor.setHandler((path) => queueConflictCheck(context, dependencies, path));
}
```
The exact extraction should be made only when conflict-checking behaviour changes. The example describes the intended ownership boundary; it is not a request to convert the Module in an unrelated documentation change.
`ConflictResolveModal` remains a focused class. One instance owns one dialogue's result promise, event subscription, and close lifetime, which is stable identity and resource ownership rather than application composition. This preserves the distinction between a useful object lifetime and a legacy Module used as a service locator.
## Interaction-based testing
+384
View File
@@ -0,0 +1,384 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Project glossary
This glossary records stable, project-specific meanings used by Self-hosted
LiveSync. Ordinary English and established technology terms retain their usual
meanings unless they are defined here. Exact code identifiers, API names, and
user-interface labels retain their source spelling.
The sections describe the intended audience, not a visibility guarantee. A
term in the developer and design section can appear in code, tests, logs, or
diagnostics. That does not make it user-interface vocabulary or a public
extension contract.
## User-facing and operational terms
These terms can appear in the user interface, user documentation, setup and
recovery guidance, or diagnostics intended for users.
### AR
- **Boot-up sequence (boot sequence):** The initialisation process of the
plug-in when Obsidian starts. It begins with loading the plug-in, setting up
core services, loading saved settings, and opening the local database. After
the layout is ready, the plug-in checks for flag files, runs configuration
diagnostics, connects to the remote database, and begins file watching. The
sequence finishes when the plug-in is ready and operational.
- **Broken files (size mismatch):** A state where a file's Metadata and the
content stored in its Chunks do not match, causing file retrieval or
synchronisation failures. Inspect these mismatches with **Inspect conflicts
and file/database differences** in the Hatch pane, then handle one exact
revision at a time.
- **Chunk / Chunks:** Divided units of data stored in the database or Object
Storage to support efficient synchronisation.
- **Compaction:** A database maintenance procedure which discards old
historical document revisions to reduce remote database size.
- **Continuous replication:** A provider's long-running replication mode. It
remains active to exchange changes until stopped and is distinct from a
finite OneShot Sync operation.
- **Custom HTTP Handler / Use Internal API (CORS bypass settings):** Settings
which bypass CORS restrictions by routing requests through Obsidian's native
request APIs. There are separate settings for each central remote type:
- **S3-compatible Object Storage (`useCustomRequestHandler`):** Labelled
**Use Custom HTTP Handler** in the standard settings tab and **Use internal
API** in the Svelte Setup Wizard dialogue. It is represented as `useProxy`
in Setup URI query parameters for compatibility.
- **CouchDB (`useRequestAPI`):** Labelled **Use Request API to avoid
inevitable CORS problem** in the standard settings tab and **Use Internal
API** in the Svelte Setup Wizard dialogue. It is represented as
`useRequestAPI` in Setup URI query parameters.
- **Customisation Sync:** The feature which synchronises settings, snippets,
themes, and plug-ins. Write 'Customisation' with an 's' in documentation;
technical configuration and links can use `customization` where required.
- **Database Adapter (IDB and IndexedDB):** The local database storage
interface used by PouchDB. The `IDB` adapter is recommended because the older
`IndexedDB` adapter is obsolete and can cause memory leaks in LiveSync mode.
Switching adapters requires local data migration and an Obsidian restart,
but not a full database rebuild.
- **Database Suffix (`additionalSuffixOfDatabaseName`):** A suffix appended to
the database name so that multiple Vaults with the same name can synchronise
to the same remote server.
- **E2EE Algorithm:** The cryptographic algorithm version used for end-to-end
encryption. All synchronising devices must use a compatible version, such as
`V2` or `V1`.
- **Eden (Eden Chunks):** A sunset-compatibility optimisation in which newly
created Chunks are held inside the document until they stabilise, before
becoming independent Chunks.
- **Fast Setup (Simple Fetch):** The preferred automated initial
synchronisation flow for a secondary device. It uses Streaming replication
for the initial download and delays local file reflection to avoid temporary
synchronisation warnings.
- **Fast Fetch:** The CouchDB-specific Streaming replication path used by Fast
Setup. It reads the changes feed in bounded pages and persists a checkpoint
so an interrupted transfer can resume. An ineligible transport uses the
ordinary fetch path instead.
- **Flag files (`redflag.md`, `redflag2.md`, and `redflag3.md`):** Special
Markdown files or directories at the Vault root which stop the boot-up
sequence or trigger recovery work. `redflag.md` suspends all processes,
`redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild, and
`redflag3.md` (`flag_fetch.md`) discards and fetches the local database again.
- **Garbage Collection (GC):** The maintenance process which identifies Chunk
documents not reachable from a current file or conflict branch, records
logical deletions for them, propagates those deletions, and requests remote
compaction to reclaim storage.
- **Hatch (Hatch pane):** The troubleshooting and maintenance section in the
plug-in settings. It contains diagnostics, database reset controls, status
reports, and advanced edge-case settings.
- **Hidden File Sync:** The feature which synchronises files in hidden
directories, such as `.obsidian`.
- **JWT Authentication:** An experimental CouchDB authentication option which
uses a JSON Web Token instead of standard credentials. It requires a private
key or secret, algorithm, expiry duration, subject, and key ID.
- **LiveSync:** This name has two established meanings: the shortened plug-in
name for Self-hosted LiveSync, and the Sync Mode for continuous, real-time
synchronisation. Prefer 'Continuous replication' in design documentation
when the mode, rather than the product, is meant.
- **livesync-serverpeer / WebPeer:** Specialised clients which assist WebRTC
peer-to-peer communication.
- **Metadata (file metadata):** A database document which stores file
properties, including its name, path, size, modification time, and references
to the Chunks containing its content. PouchDB or CouchDB revision metadata
carries conflict state; the file Metadata document has no separate history
field. Metadata and file content are stored separately.
- **OneShot Sync (OneShot replication):** One finite bidirectional
synchronisation operation, normally pull then push, which is requested
directly or by an event. It is distinct from Continuous replication.
- **Overwrite Server Data with This Device's Files:** A maintenance operation,
formerly named `Rebuild everything`, which discards the remote database and
rebuilds the local and remote databases from the current files on one
authoritative device.
- **Path Obfuscation:** A privacy option which encrypts file paths and folder
names on the remote server.
- **plug-in:** The spelling used in user-facing messages and general prose.
Retain `plugin` in code, configuration, and established technical names.
- **Remediation (`maxMTimeForReflectEvents`):** A recovery setting which limits
reflection of changes from the database to the Vault by ignoring file events
after a specified date and time.
- **Reset Synchronisation on This Device:** A maintenance operation, formerly
named `Fetch everything`, which discards the local database and rebuilds it
from the remote database.
### Revision
A revision is a version of one PouchDB or CouchDB document. Concurrent changes
can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one can apply to
the same revision:
- **leaf:** Has no known child revision.
- **winner:** Is the leaf selected by PouchDB or CouchDB as the current
document.
- **conflict:** Is another current leaf which was not selected as the winner.
- **Vault-matching:** Represents the same file content, or the same absent-file
state, as the current Vault. More than one revision can match.
- **displayed:** Is recorded by valid device-local file provenance as the
branch represented in the Vault. A pending local edit might no longer match
its bytes, but still extends this recorded branch.
- **logically deleted:** Represents absence of the file through a deletion
marker. A logically deleted revision can also be a leaf, winner, conflict,
or Vault-matching revision. An absent file retains no displayed provenance.
Avoid 'live revision' because it can mean either a current leaf or a
non-deleted revision. See
[Independent revision properties](specs_conflict_resolution.md#independent-revision-properties)
for the relationship between revision-tree roles, Vault state, and
device-local provenance.
### SZ
- **Scram (Scram Switches):** Emergency controls which suspend file watching or
database reflection to reduce the risk of corruption or unintended changes.
- **Security Seed:** The remote PBKDF2 salt used to derive the encryption key
for replication. It must be read from, or established on, the remote before
encrypted synchronisation.
- **Segmenter (Segmented-splitter):** A chunking method which divides files at
semantic boundaries, such as paragraphs or sections, rather than arbitrary
byte boundaries.
- **Self-hosted LiveSync:** The name of this plug-in. 'Self-hosted' is one
hyphenated word.
- **Setting Doctor (Config Doctor):** A diagnostic utility which identifies
configuration mismatches or suboptimal settings and presents recommended
values and reasons.
- **Setup URI:** An encrypted representation of plug-in settings and remote
configuration which can be transferred to another device and opened with a
passphrase.
- **Signalling relay (P2P):** A Nostr-compatible WebSocket relay used for peer
discovery and WebRTC connection negotiation. It does not store or transfer
Vault content. The project author operates a public relay as a best-effort
convenience, and users can supply another compatible relay.
- **Streaming replication (stream-based replication):** A transfer method
which downloads database documents as a continuous stream of events. Fast
Setup uses it to retrieve remote Metadata efficiently.
- **Sync Mode:** The trigger mechanism for synchronisation. Current modes are
**LiveSync**, for continuous replication, **Periodic Sync**, for work at a
configured interval, and **On Events**, for configured application events.
- **Synchronising devices:** Devices which participate in the same
synchronisation for a Vault. The term describes membership rather than
current activity, so it includes offline and idle devices.
- **TURN Server (WebRTC P2P):** A Traversal Using Relays around NAT server used
as an optional fallback when NAT or firewall rules prevent a direct WebRTC
connection. It relays encrypted WebRTC traffic and is distinct from the
signalling relay.
- **Update Thinning (Batch database update):** An optimisation which groups
local file edits over a short delay before committing them to the local
database, reducing database writes.
- **WebRTC P2P (peer-to-peer):** A synchronisation method which allows devices
to communicate directly without a central remote database.
## Developer and design terms
These definitions are stable vocabulary for architecture documents, ADRs,
implementation, tests, and code review. They might never appear in the user
interface. Inclusion here fixes their project meaning; it does not make the
named surface a public API or extension point.
### Active publication
The atomic publication of one Replicator provider, its `ReplicatorInstance`,
and its configuration identity, owned by Commonlib's `ReplicatorService`. Its
object identity is the admission fence for operations. An active publication
is also called the active Replicator publication, and its instance is the
**active Replicator**. 'Active publication' is more precise than 'current
Replicator' when admission or retirement matters.
### Adjunct P2P transport
P2P operating as an additional transport while CouchDB or Object Storage is
the selected main remote. It retains its own service and room-session
ownership; it is not the active Replicator for the main remote. Architecture
documents can shorten this to 'adjunct P2P' where the distinction is already
clear.
### Admission and reservation
**Admission** is permission for an operation to use one exact active
publication or P2P room session. A **reservation** records admitted work and
keeps its owner alive until that work settles. Retirement closes admission
before it waits for existing reservations, so later work cannot enter the
retiring generation.
### Bounded remote activity
A finite logical operation which can involve remote work, waiting, queueing, or
local result handling. Its lifetime is broader than an individual network
request. Continuous replication is not bounded remote activity. See the
[Bounded Remote Activity ADR](adr/2026_07_bounded_remote_activity.md).
### Capability
A typed declaration that a Replicator provider supports an operation or remote
resource, does not implement it, or considers it inapplicable. Capability
support is explicit; callers do not infer it from a legacy method, a Boolean
default, or a neutral return value.
### Central remote
A CouchDB or Object Storage remote which can require central preparation and
administration before replication. P2P is not a central remote. The **main
remote** is the `RemoteType` selected for the active Replicator; P2P can also
operate as an additional transport when a central remote is selected.
### Configuration identity
An opaque projection of the effective settings which determine whether an
existing provider instance or P2P binding can be retained. It can contain
credentials. Code can compare an identity for equality, but must not inspect,
log, persist, or display it.
### Fence, generation, and epoch
A **fence** prevents stale work or work admitted by one owner from affecting a
replacement owner or state. A **generation** normally changes when one local
lifecycle is invalidated. An **epoch** identifies one session or data history
where the owning contract uses that term. These values belong to distinct state
machines and are not interchangeable or evidence that another owner is
current.
### Focused view
A narrow interface exposing only the operations required by a consumer. It
delegates to a stable owner and does not independently own the underlying
mutable state or resource.
### Interaction authority
The explicit upper bound on user interaction permitted during an operation.
User-initiated work can receive selected permissions; unattended work carries
`NO_INTERACTION` and cannot open a dialogue, request peer selection, or obtain
authority through a fallback path.
### Journal remote epoch
The `protocolVersion:pbkdf2salt` value stored as
`CheckPointInfo.journalEpoch`. It identifies Journal checkpoint and
deduplication-cache history. It is data-history state, not a cancellation,
Replicator retirement, or operation-admission fence.
### Non-owning adapter
An adapter which implements a contract by delegating to another component
without owning the delegated resource. Closing it releases only resources
which the adapter itself owns. In particular, closing the active P2P adapter
does not close the stable P2P service or its room session.
### Owner and ownership
The **owner** is the single component responsible for creating, replacing,
stopping, and disposing a resource or stateful lifecycle. A borrower, adapter,
or focused view can use that resource only within its declared boundary and
must not perform the owner's lifecycle operations.
### P2P service, room session, and demand
The **P2P service** is the stable Commonlib owner which supplies focused views
and owns replaceable room sessions. A **P2P room session** is one active room
membership and the resources whose validity depends on it. **Demand** is one
persistent or finite reason for the owner to retain a room. Releasing one
demand does not close a room retained by another. A room's effective binding
includes the settings, local database object, and device identity which make
that session valid. A **session epoch** is the internal identity and fence of
one room-session object, not a persisted room name or a public numeric counter.
An **automation baseline** records peers for which the initial transfer
completed in the current logical automation lifecycle; it is owned
independently of a replaceable room session. A **configured target** is a
persisted peer name selected for unattended `P2P_SyncOnReplication`; the
request can wait for its advertisement, but cannot prompt for peer selection.
### Publication retirement
The lifecycle transition which removes an active publication from current
admission, asks its provider to stop transfer work, drains reservations for
that exact publication, closes the old instance, and marks retirement
complete. **Quiescing** is the state after admission has closed and before
retirement completes. A replacement cannot be published across an incomplete
retirement fence. A **candidate** is a newly constructed instance which remains
private until initialisation and freshness checks permit atomic publication.
### Remote resource and probe
A **remote resource** is a provider-declared, caller-owned object created from
one effective-settings snapshot for a bounded task. A **probe** is a bounded,
flow-specific validation or observation for connection, compatibility, setup,
or diagnostics. It can use an owned remote resource or an owner-arbitrated P2P
trial. It does not publish or replace the active Replicator, and the caller
disposes every resource which it owns.
### Replicator
The project abstraction which performs replication for one configured remote
kind and implements the `ReplicatorInstance` lifecycle contract. Use
'replication' for the process and 'Replicator' for this runtime abstraction. A
Replicator can be an owning transport implementation or a non-owning adapter;
the provider contract determines the boundary.
### Replicator provider definition
The exhaustive, host-composed declaration for one `RemoteType`: its
configuration identity, Replicator factory, readiness requirement,
capabilities, remote-resource factories, operation runners, and optional
central administration. The readiness requirement declares which layer must
establish operation preconditions. The provider catalogue is **closed
composition**, not a runtime registry: adding a provider requires changing,
shipping, and testing the owning composition. A **built-in provider** is one
included in that shipped catalogue. Architecture prose can shorten 'Replicator
provider definition' to 'provider'; it does not mean only the transport
instance.
### Replication outcome
The typed settlement of an attempted replication operation, represented by
`ReplicationOutcome`. Completed, partial, blocked, cancelled, and failed states
remain explicit; `undefined`, an empty value, or a compatibility default is not
treated as successful work.
### Service composition terms
- A **Service Hub** is the long-lived registry of service contracts for one
application composition.
- A **Service** owns a stable shared capability and its lifecycle.
- A **ServiceModule** is a host-created, long-lived stateful or resource-owning
capability shared through the typed `ServiceModules` record.
- A **serviceFeature** is a typed composition function which accepts declared
Services and ServiceModules, registers host integration, and can return a
focused view. It is not a runtime registry entry.
- A **legacy Module** is an existing application structure retained for
compatibility. New behaviour does not acquire the complete core merely to
imitate that locator pattern.
See [Service feature and legacy Module boundaries](design_docs/service_feature_and_legacy_module_boundaries.md)
for the selection and composition rules.
### Suspension
A reversible lifecycle action which stops active transfer work without
retiring the active Replicator publication. The P2P service also closes its
current room session during application suspension because that session has a
separate owner and lifecycle. Resumption can retain the Replicator instance and
open a new P2P room as required.
+1 -1
View File
@@ -721,7 +721,7 @@ When a saved setting changes whether a normal file can be reflected, LiveSync re
## 6. Customisation sync (Advanced)
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for snippets, configuration files, and plug-in files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
Customisation Sync is a supported, advanced opt-in feature. Its current per-file implementation is covered by a two-Vault real-Obsidian workflow for configuration files, themes, snippets, and plug-in main, data, and supplementary files. Hidden File Sync is a separate feature with different setup, selection, and conflict behaviour; do not use both features to manage the same files.
### 1. Customisation Sync
+1 -1
View File
@@ -20,7 +20,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
### Independent revision properties
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
The modifiers defined under [Revision](glossary.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
| Situation | Winner | Vault-matching | Displayed |
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
+16 -1
View File
@@ -11,6 +11,21 @@
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
## Current technical references
- [Database Data Structures](datastructure.md) describes current Metadata and
Chunk shapes, identifier handling, deletion, and raw remote representations.
- [Replicator architecture](design_docs/replicator_architecture.md) describes
provider composition, active Replicator publication, retirement, and P2P
ownership.
- [Conflict resolution and revision provenance](specs_conflict_resolution.md)
defines the current revision-tree and file-provenance rules.
- [Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md)
defines missing-Chunk arrival and quiescence handling.
- [Data Compression](specs_data_compression.md) and [Garbage Collection
V3](specs_garbage_collection.md) describe their respective storage and
maintenance contracts.
## Techniques to keep bandwidth consumption low.
![dedupe](../images/2.png)
![dedupe](../images/2.png)
+9 -94
View File
@@ -1,3 +1,10 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Notes on Terminology, Spelling, Vocabulary Conventions
## Spelling and Vocabulary conventions
@@ -24,97 +31,5 @@ All guidelines and conventions listed below are disclosed and maintained solely
### Terminology
- Boot-up sequence (boot-sequence)
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
- Broken files (Size mismatch)
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
- Chunk / Chunks
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
- Compaction
- A database maintenance procedure that discards old historical document revisions to shrink the remote database size.
- Custom HTTP Handler / Use Internal API (CORS Bypass Settings)
- Settings used to bypass CORS restrictions by routing requests through Obsidian's native request APIs. There are two distinct settings under the hood depending on the remote server type:
- **For S3-compatible Object Storage (useCustomRequestHandler)**: Labeled as **"Use Custom HTTP Handler"** in the standard settings tab, **"Use internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useProxy` in the Setup URI's query parameters due to an unfortunate misunderstanding during development.
- **For CouchDB (useRequestAPI)**: Labeled as **"Use Request API to avoid `inevitable` CORS problem"** in the standard settings tab, **"Use Internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useRequestAPI` in the Setup URI's query parameters.
- Customisation Sync
- The feature that synchronises settings, snippets, themes, and plug-ins. Write with an "s" in documentation (`Customisation`), though technical configurations and links may use `customization`.
- Database Adapter (IDB vs. IndexedDB)
- The local database storage interface used by PouchDB. The `IDB` adapter is recommended since the older `IndexedDB` adapter is obsolete and known to cause memory leaks in `LiveSync` mode. Users can switch between these adapters without a full database rebuild, although a local data migration and an Obsidian restart are required.
- Database Suffix (additionalSuffixOfDatabaseName)
- A unique suffix appended to the database name to allow synchronising multiple vaults with the same name on the same remote server.
- E2EE Algorithm
- The cryptographic algorithm version used for end-to-end encryption. All synchronising devices must be configured with a compatible version (such as `V2` or `V1`).
- Eden (Eden Chunks)
- A performance optimisation where newly created chunks are held within the document until they stabilise, before graduating to independent chunks.
- Fast Setup (Simple Fetch)
- A simplified, automated initial synchronisation flow triggered when setting up subsequent devices or recovering a database. It bypasses the detailed step-by-step setup wizard dialogues, prompting the user with high-level data processing decisions and completing the initial download and local file scan in one continuous process.
- Flag files (redflag.md, redflag2.md, redflag3.md)
- Special Markdown files (or directories) placed at the root of the vault to stop the boot-up sequence or trigger recovery tasks. For instance, `redflag.md` suspends all processes, while `redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild and `redflag3.md` (`flag_fetch.md`) discards the local database to fetch it again from the remote.
- Garbage Collection (GC)
- The process of identifying and purging unreferenced chunks (unused data) from local and remote databases to reclaim storage space.
- Hatch (Hatch pane)
- A dedicated troubleshooting and maintenance section in the plug-in settings, typically hidden behind a warning-labeled collapsible panel to prevent accidental misconfiguration. It contains diagnostic utilities, database reset controls, status reports, and advanced edge-case patches.
- Hidden File Sync
- The feature that synchronises files located in hidden directories (like `.obsidian`).
- JWT Authentication
- An experimental authentication option for CouchDB allowing secure token-based authentication instead of standard credentials. It requires a configured private key/secret, algorithm, expiration duration, subject, and key ID.
- LiveSync
- A very confusing term.
- As a shortened form of `Self-hosted LiveSync`.
- As the name of a synchronisation mode. This should be changed to `Continuous`, in contrast to `Periodic`.
- livesync-serverpeer / webpeer
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
- Metadata (File metadata)
- A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
- OneShot Sync
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
- Overwrite Server Data with This Device's Files
- A maintenance operation (formerly known as `Rebuild everything`) that discards the remote database and reconstructs it by uploading all current local files as a fresh database, overwriting any remote changes.
- Path Obfuscation
- A privacy option that encrypts file paths and folder names on the remote server.
- plug-in
- We use the hyphenated form `plug-in` in user-facing messages and general documentation, while `plugin` may appear in codebase files, configuration settings, or technical contexts.
- Signalling relay (P2P)
- A Nostr-compatible WebSocket relay used for peer discovery and WebRTC connection negotiation. It does not store or transfer Vault contents. The project author operates a public relay as a best-effort convenience, and users can provide another compatible relay.
- Remediation (maxMTimeForReflectEvents)
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
- Reset Synchronisation on This Device
- A maintenance operation (formerly known as `Fetch everything`) that discards the local database and reconstructs it by downloading all data from the remote server.
#### Revision
A revision is a version of one PouchDB/CouchDB document. Concurrent changes can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one may apply to the same revision:
- **leaf**: Has no known child revision.
- **winner**: Is the leaf selected by PouchDB/CouchDB as the current document.
- **conflict**: Is another current leaf which was not selected as the winner.
- **Vault-matching**: Represents the same file contents, or the same absent-file state, as the current Vault. More than one revision may match.
- **displayed**: Is recorded by valid device-local file provenance as the branch represented in the Vault. A pending local edit may no longer match its bytes, but still extends this recorded branch.
- **logically deleted**: Represents the absence of the file through a deletion marker. A logically deleted revision may also be a leaf, winner, conflict, or Vault-matching revision. An absent file retains no displayed provenance.
Avoid **live revision** in prose because it can ambiguously mean either a current leaf or a non-deleted revision. See [Independent revision properties](specs_conflict_resolution.md#independent-revision-properties) for the relationship between revision-tree roles, Vault state, and device-local provenance.
- Scram (Scram Switches)
- Emergency controls in the settings that allow users to suspend file watching or database writes to prevent corruption.
- Segmenter (Segmented-splitter)
- A chunking method that divides files on semantic boundaries (such as paragraphs or sections) rather than arbitrary byte boundaries.
- Self-hosted LiveSync
- The name of this plug-in. `Self-hosted` is one word.
- Setting Doctor (Config Doctor)
- A diagnostic utility that checks for mismatches or suboptimal configurations, presenting users with ideal values and recommendation reasons to easily resolve issues during migration, configuration import, or general troubleshooting.
- Setup URI
- An encrypted representation of the plug-in's settings containing server configuration, which allows users to clone their configuration across devices securely using a passphrase.
- Streaming replication (Stream-based replication)
- A data transfer method that downloads database documents as a continuous stream of events. It is significantly faster than traditional chunk-by-chunk HTTP requests and is used during Fast Setup to retrieve remote metadata quickly.
- Sync Mode
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
- Synchronising devices
- Devices which participate in the same synchronisation for a Vault. The term describes membership rather than current activity, so it includes offline and idle devices.
- TURN Server (WebRTC P2P)
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
- Update Thinning (Batch database update)
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
- WebRTC P2P (Peer-to-Peer)
- A synchronisation method enabling direct communication between devices without a central server database.
Project-specific meanings are defined separately in the
[Project glossary](glossary.md).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.23",
"version": "1.0.24",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.23",
"version": "1.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.23",
"version": "1.0.24",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -32,7 +32,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
@@ -9682,9 +9682,9 @@
"license": "MIT"
},
"node_modules/octagonal-wheels": {
"version": "0.1.53",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.53.tgz",
"integrity": "sha512-4NJsb96Sk6rJXhrTyjAY5GRIoWMFJsFp56b5ba9fV8/87ys2HCjMo0hior4F8k4ma72pLTJT7i6DvuihHxSsMA==",
"version": "0.1.54",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.54.tgz",
"integrity": "sha512-Je3ancYhjKX7UY2K19T/qTjG8C9nK8YVrACr5naIf78mN4bbjQkYyWmlj+ooifV/moWVsQrp4fEWz/7mv6It3A==",
"license": "MIT",
"dependencies": {
"idb": "^8.0.3"
@@ -12937,7 +12937,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.23-cli",
"version": "1.0.24-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -12962,7 +12962,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.23-webapp",
"version": "1.0.24-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
@@ -12974,7 +12974,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.23-webpeer",
"version": "1.0.24-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.23",
"version": "1.0.24",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -186,7 +186,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
+6 -24
View File
@@ -22,17 +22,16 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { AbstractModule } from "./modules/AbstractModule";
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
import { useReplicationFeature } from "./serviceFeatures/replication";
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
/** Focused views returned by serviceFeatures which the host may consume during composition. */
export interface LiveSyncCoreFeatureViews {
@@ -45,10 +44,7 @@ export class LiveSyncBaseCore<
T extends ServiceContext = ServiceContext,
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
>
implements
LiveSyncLocalDBEnv,
LiveSyncCouchDBReplicatorEnv,
HasSettings<ObsidianLiveSyncSettings>
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
{
addOns = [] as TCommands[];
@@ -62,18 +58,6 @@ export class LiveSyncBaseCore<
this.services.appLifecycle.onUnload.addHandler(() => Promise.resolve(addOn.onunload()).then(() => true));
}
/**
* Get an add-on by its class name. Returns undefined if not found.
* @param cls
* @returns
*/
getAddOn<T extends TCommands>(cls: string) {
for (const addon of this.addOns) {
if (addon.constructor.name == cls) return addon as T;
}
return undefined;
}
constructor(
serviceHub: InjectableServiceHub<T>,
serviceModuleInitialiser: (
@@ -95,9 +79,10 @@ export class LiveSyncBaseCore<
for (const addOn of addOns) {
this._registerAddOn(addOn);
}
// Register host features and add-ons before replication, then bind
// Compose late core features after host features and add-ons, then bind
// legacy modules so lifecycle handlers observe the required order.
useReplicationFeature(this);
useBasicCommandsFeature(this);
this.bindModuleFunctions();
}
/**
@@ -157,10 +142,7 @@ export class LiveSyncBaseCore<
public registerModules(extraModules: AbstractModule[] = []) {
this._registerModule(new ModuleLiveSyncMain(this));
this._registerModule(new ModuleConflictChecker(this));
this._registerModule(new ModuleConflictResolver(this));
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
this._registerModule(new ModuleBasicMenu(this));
for (const module of extraModules) {
this._registerModule(module);
@@ -290,6 +272,7 @@ export class LiveSyncBaseCore<
* (Please refer `serviceFeatures` for more details)
*/
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
useConflictResolutionFeature(this);
useTargetFilters(this);
// enable target filter feature.
usePrepareDatabaseForUse(this);
@@ -304,5 +287,4 @@ export class LiveSyncBaseCore<
export interface IMinimumLiveSyncCommands {
onunload(): void;
onload(): void | Promise<void>;
constructor: { name: string };
}
+44 -32
View File
@@ -48,7 +48,7 @@ CLI Main
- Settings management (JSON file)
- Graceful shutdown handling
## Usage
## Command overview
The CLI operates on a **database directory** which contains PouchDB data and settings.
@@ -151,6 +151,46 @@ npm run cli -- [database-path] [command] [args...]
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
```
### systemd installation
The `deploy/` directory contains a systemd unit template and an install script.
**Automated installation (user service, recommended):**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
```
**With a polling interval:**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
```
**System-wide installation** (requires root or `sudo` for `/etc/systemd/system/`):
```bash
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
```
The script:
1. Installs the repository dependencies and builds the CLI.
2. Installs the complete CLI bundle and its production dependencies under `~/.local/lib/livesync-cli` (user) or `/usr/local/lib/livesync-cli` (system), then checks that the installed CLI can start.
3. Installs the command wrapper as `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
4. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
5. Reloads systemd, enables and starts the service, and reports success only after confirming that the service remains active.
Ensure that `~/.local/bin` for a user installation, or `/usr/local/bin` for a system-wide installation, is on the shell's `PATH` before invoking `livesync-cli` interactively. For example, add the following to the appropriate shell start-up file for a user installation when needed:
```bash
export PATH="$HOME/.local/bin:$PATH"
```
The generated systemd unit uses the wrapper's absolute path and does not depend on the shell's `PATH`.
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and Vault path, then install it in the appropriate systemd directory.
### Docker
A Docker image is provided for headless / server deployments. Build from the repository root:
@@ -210,7 +250,9 @@ candidate carries the host's public IP and peers can connect normally.
### Adding `livesync-cli` alias
To use the `livesync-cli` command globally, you can add an alias to your shell configuration file (e.g., `.zshrc` or `.bashrc`).
If you used the [systemd installer](#systemd-installation), no alias is required: it installs the `livesync-cli` wrapper in `~/.local/bin` or `/usr/local/bin`. If the installed command is not found, follow the `PATH` guidance in the systemd installation section.
The aliases below are only for running the CLI from a source checkout, or from Docker without using the installer. Add the appropriate alias to your shell configuration file, such as `.zshrc` or `.bashrc`.
If you are using `npm run`, add the following line:
@@ -506,36 +548,6 @@ Patterns apply in both directions: the chokidar watcher will not emit events for
Changes to this file require a daemon restart to take effect.
### Systemd Installation
The `deploy/` directory contains a systemd unit template and an install script.
**Automated install (user service, recommended):**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
```
**With polling interval:**
```bash
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
```
**System-wide install** (requires root / sudo for `/etc/systemd/system/`):
```bash
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
```
The script:
1. Builds the CLI (`npm install` + `npm run build`).
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
### Planned options:
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
+57 -8
View File
@@ -8,7 +8,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)"
CLI_DIR="$REPO_ROOT/src/apps/cli"
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
@@ -104,30 +104,70 @@ fi
# ── Install binary ───────────────────────────────────────────────────────────
if [[ "$INSTALL_MODE" == "user" ]]; then
BIN_DIR="$HOME/.local/bin"
LIB_DIR="$HOME/.local/lib/livesync-cli"
UNIT_DIR="$HOME/.config/systemd/user"
SYSTEMCTL_FLAGS="--user"
else
BIN_DIR="/usr/local/bin"
LIB_DIR="/usr/local/lib/livesync-cli"
UNIT_DIR="/etc/systemd/system"
SYSTEMCTL_FLAGS=""
fi
mkdir -p "$BIN_DIR"
LIB_PARENT="$(dirname -- "$LIB_DIR")"
mkdir -p "$BIN_DIR" "$LIB_PARENT"
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
LIVESYNC_JS="$LIB_DIR/dist/index.cjs"
# Copy the CJS bundle so the wrapper is self-contained and independent of the
# build directory location.
cp "$BUILT_CJS" "$LIVESYNC_JS"
# Build a complete runtime payload before replacing any previous installation.
# The Vite output contains hashed sibling chunks, while some Node dependencies
# deliberately remain external and must be installed next to the bundle.
PAYLOAD_STAGING="$(mktemp -d "$LIB_PARENT/.livesync-cli.install.XXXXXX")"
cleanup_payload() {
if [[ -n "$PAYLOAD_STAGING" ]] && [[ -e "$PAYLOAD_STAGING" ]]; then
rm -rf -- "$PAYLOAD_STAGING"
fi
}
trap cleanup_payload EXIT
# Write a bash wrapper that invokes node on the installed bundle.
cp "$CLI_DIR/package.json" "$PAYLOAD_STAGING/package.json"
npm install --omit=dev --no-audit --no-fund --prefix "$PAYLOAD_STAGING"
cp -R "$CLI_DIR/dist" "$PAYLOAD_STAGING/dist"
if ! node "$PAYLOAD_STAGING/dist/index.cjs" --help >/dev/null; then
echo "Error: installed CLI failed its start-up check" >&2
exit 1
fi
PAYLOAD_BACKUP=""
if [[ -e "$LIB_DIR" ]] || [[ -L "$LIB_DIR" ]]; then
PAYLOAD_BACKUP="$(mktemp -d "$LIB_PARENT/.livesync-cli.backup.XXXXXX")"
rmdir "$PAYLOAD_BACKUP"
mv -- "$LIB_DIR" "$PAYLOAD_BACKUP"
fi
if ! mv -- "$PAYLOAD_STAGING" "$LIB_DIR"; then
if [[ -n "$PAYLOAD_BACKUP" ]]; then
mv -- "$PAYLOAD_BACKUP" "$LIB_DIR"
fi
echo "Error: failed to install the CLI files at $LIB_DIR" >&2
exit 1
fi
PAYLOAD_STAGING=""
if [[ -n "$PAYLOAD_BACKUP" ]]; then
rm -rf -- "$PAYLOAD_BACKUP"
fi
trap - EXIT
# Write a bash wrapper that invokes Node.js on the installed payload.
cat > "$LIVESYNC_BIN" <<WRAPPER
#!/usr/bin/env bash
exec node "$LIVESYNC_JS" "\$@"
WRAPPER
chmod +x "$LIVESYNC_BIN"
echo "[INFO] Installed bundle: $LIVESYNC_JS"
echo "[INFO] Installed CLI files: $LIB_DIR"
echo "[INFO] Installed binary: $LIVESYNC_BIN"
# ── Write systemd unit ───────────────────────────────────────────────────────
@@ -180,6 +220,15 @@ systemctl $SYSTEMCTL_FLAGS daemon-reload
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
sleep 1
# shellcheck disable=SC2086
if ! systemctl $SYSTEMCTL_FLAGS is-active --quiet livesync-cli; then
echo "Error: livesync-cli service did not remain active after startup." >&2
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
exit 1
fi
echo ""
echo "[Done] livesync-cli service installed and started."
echo ""
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const deploySourceDirectory = dirname(fileURLToPath(import.meta.url));
const temporaryDirectories: string[] = [];
type InstallerFixture = {
cliDirectory: string;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
installerPath: string;
npmCallLog: string;
repositoryRoot: string;
systemctlCallLog: string;
vaultDirectory: string;
};
async function writeExecutable(path: string, content: string): Promise<void> {
await writeFile(path, `${content}\n`, "utf8");
await chmod(path, 0o755);
}
async function createInstallerFixture(serviceActive: boolean): Promise<InstallerFixture> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-cli-installer-"));
temporaryDirectories.push(temporaryDirectory);
const repositoryRoot = join(temporaryDirectory, "repository");
const cliDirectory = join(repositoryRoot, "src", "apps", "cli");
const deployDirectory = join(cliDirectory, "deploy");
const distDirectory = join(cliDirectory, "dist");
const fakeBinDirectory = join(temporaryDirectory, "fake-bin");
const homeDirectory = join(temporaryDirectory, "home");
const vaultDirectory = join(temporaryDirectory, "vault");
const npmCallLog = join(temporaryDirectory, "npm-calls.log");
const systemctlCallLog = join(temporaryDirectory, "systemctl-calls.log");
await Promise.all([
mkdir(deployDirectory, { recursive: true }),
mkdir(distDirectory, { recursive: true }),
mkdir(fakeBinDirectory, { recursive: true }),
mkdir(homeDirectory, { recursive: true }),
mkdir(vaultDirectory, { recursive: true }),
]);
await Promise.all([
copyFile(join(deploySourceDirectory, "install.sh"), join(deployDirectory, "install.sh")),
copyFile(join(deploySourceDirectory, "livesync-cli.service"), join(deployDirectory, "livesync-cli.service")),
writeFile(
join(repositoryRoot, "package.json"),
JSON.stringify({ private: true, workspaces: ["src/apps/*"] }),
"utf8"
),
writeFile(
join(cliDirectory, "package.json"),
JSON.stringify({
name: "self-hosted-livesync-cli",
private: true,
version: "0.0.0",
dependencies: { "fixture-runtime-dependency": "1.0.0" },
}),
"utf8"
),
writeFile(
join(distDirectory, "index.cjs"),
'const chunk = require("./chunk.cjs");\n' +
'const dependency = require("fixture-runtime-dependency");\n' +
"process.stdout.write(`${chunk}:${dependency}\\n`);\n",
"utf8"
),
writeFile(join(distDirectory, "chunk.cjs"), 'module.exports = "chunk-ready";\n', "utf8"),
]);
await writeExecutable(
join(fakeBinDirectory, "npm"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s|%s\\n\' "$PWD" "$*" >> "$NPM_CALL_LOG"',
'prefix=""',
"expect_prefix=0",
'for argument in "$@"; do',
' if [[ "$expect_prefix" -eq 1 ]]; then',
' prefix="$argument"',
" expect_prefix=0",
' elif [[ "$argument" == "--prefix" ]]; then',
" expect_prefix=1",
" fi",
"done",
'if [[ -n "$prefix" ]]; then',
' mkdir -p "$prefix/node_modules/fixture-runtime-dependency"',
" printf '%s\\n' 'module.exports = \"dependency-ready\";' > \"$prefix/node_modules/fixture-runtime-dependency/index.js\"",
"fi",
].join("\n")
);
await writeExecutable(
join(fakeBinDirectory, "systemctl"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s\\n\' "$*" >> "$SYSTEMCTL_CALL_LOG"',
'if [[ " $* " == *" is-active "* ]]; then',
' [[ "${FAKE_SYSTEMCTL_ACTIVE:-1}" == "1" ]]',
" exit",
"fi",
'if [[ " $* " == *" status "* ]]; then',
" printf '%s\\n' \"fixture service status\"",
"fi",
].join("\n")
);
await writeExecutable(join(fakeBinDirectory, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"));
return {
cliDirectory,
environment: {
...process.env,
FAKE_SYSTEMCTL_ACTIVE: serviceActive ? "1" : "0",
HOME: homeDirectory,
NPM_CALL_LOG: npmCallLog,
PATH: `${fakeBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
SYSTEMCTL_CALL_LOG: systemctlCallLog,
},
homeDirectory,
installerPath: join(deployDirectory, "install.sh"),
npmCallLog,
repositoryRoot,
systemctlCallLog,
vaultDirectory,
};
}
function runInstaller(fixture: InstallerFixture) {
return spawnSync("bash", [fixture.installerPath, "--vault", fixture.vaultDirectory], {
encoding: "utf8",
env: fixture.environment,
});
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe.skipIf(process.platform === "win32")("CLI systemd installer", () => {
it("installs a runnable CLI independently of the source repository", async () => {
const fixture = await createInstallerFixture(true);
const installation = runInstaller(fixture);
expect(installation.error).toBeUndefined();
expect(installation.status, installation.stderr).toBe(0);
expect(installation.stdout).toContain("[Done] livesync-cli service installed and started.");
const installedCommand = join(fixture.homeDirectory, ".local", "bin", "livesync-cli");
const installedPayload = join(fixture.homeDirectory, ".local", "lib", "livesync-cli", "dist", "index.cjs");
const installedUnit = join(fixture.homeDirectory, ".config", "systemd", "user", "livesync-cli.service");
expect(await readFile(installedPayload, "utf8")).toContain('require("./chunk.cjs")');
expect(await readFile(installedUnit, "utf8")).toContain("Type=exec");
await rm(fixture.repositoryRoot, { recursive: true });
const command = spawnSync(installedCommand, [], { encoding: "utf8", env: fixture.environment });
expect(command.error).toBeUndefined();
expect(command.status, command.stderr).toBe(0);
expect(command.stdout).toBe("chunk-ready:dependency-ready\n");
const npmCalls = await readFile(fixture.npmCallLog, "utf8");
expect(npmCalls).toContain(`${fixture.repositoryRoot}|install --silent`);
expect(npmCalls).toContain(`${fixture.cliDirectory}|run build`);
expect(npmCalls).toMatch(/install .*--omit=dev|install --omit=dev/);
const systemctlCalls = await readFile(fixture.systemctlCallLog, "utf8");
expect(systemctlCalls).toContain("--user enable --now livesync-cli");
expect(systemctlCalls).toContain("--user is-active --quiet livesync-cli");
});
it("does not report success when the service fails to remain active", async () => {
const fixture = await createInstallerFixture(false);
const installation = runInstaller(fixture);
const combinedOutput = `${installation.stdout}\n${installation.stderr}`;
expect(installation.error).toBeUndefined();
expect(installation.status).not.toBe(0);
expect(combinedOutput).toContain("service did not remain active after startup");
expect(combinedOutput).not.toContain("[Done]");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Type=exec
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
Restart=on-failure
RestartSec=10
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.23-cli",
"version": "1.0.24-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.23-webapp",
"version": "1.0.24-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.23-webpeer",
"version": "1.0.24-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -7,8 +7,6 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device",
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.":
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.",
@@ -59,10 +57,6 @@ export const liveSyncProvisionalEnglishMessages = {
"Follow whenever this device connects": "Follow whenever this device connects",
"Include in the P2P synchronisation command": "Include in the P2P synchronisation command",
"More actions for ${DEVICE}": "More actions for ${DEVICE}",
"Create or connect to database and continue": "Create or connect to database and continue",
"Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting",
"Use this device's settings": "Use this device's settings",
Retry: "Retry",
"No Synchronisation Settings Found": "No Synchronisation Settings Found",
@@ -75,14 +69,6 @@ export const liveSyncProvisionalEnglishMessages = {
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
"Skips checking and applying synchronisation settings from the remote.":
"Skips checking and applying synchronisation settings from the remote.",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"Check server requirements": "Check server requirements",
"Change CouchDB server setting": "Change CouchDB server setting",
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?":
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?",
+74 -28
View File
@@ -148,16 +148,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "(正则表达式)如果已设置,则所有匹配此模式的本地和远端文件变更都会被跳过。",
"zh-tw": "(正則表示式)若已設定,所有符合此模式的本機與遠端檔案變更都會被略過。",
},
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.":
{
def: "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
es: "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
ja: "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
ko: "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
ru: "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
zh: "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"zh-tw":
"(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
def: "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.",
},
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
{
@@ -370,18 +363,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "新增连接",
"zh-tw": "新增連線",
},
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": {
def: "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
es: "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
ko: "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"zh-tw": "附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。",
},
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": {
def: "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
es: "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
ko: "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"zh-tw": "附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。",
},
Advanced: {
def: "Advanced",
es: "Avanzado",
@@ -740,6 +721,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "检查尚未转换为路径混淆 ID 的文档,并在需要时将其转换。",
"zh-tw": "檢查尚未轉換為路徑混淆 ID 的文件,並在需要時進行轉換。",
},
"Check server requirements": {
def: "Check server requirements",
es: "Comprobar los requisitos del servidor",
},
"Checking connection... Please wait.": {
def: "Checking connection... Please wait.",
es: "Comprobando la conexión... Espera un momento.",
@@ -993,6 +978,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "연결",
"zh-tw": "連線",
},
"Connect to existing database and continue": {
def: "Connect to existing database and continue",
es: "Conectar a la base de datos existente y continuar",
},
"Connected to Signaling Server (as Peer ID: ${peerId})": {
def: "Connected to Signaling Server (as Peer ID: ${peerId})",
es: "Conectado al servidor de señalización (como ID de par: ${peerId})",
@@ -1094,6 +1083,14 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "CouchDB 连接调优",
"zh-tw": "CouchDB 連線調校",
},
"CouchDB validates the database name when you connect. The name must not be empty.": {
def: "CouchDB validates the database name when you connect. The name must not be empty.",
es: "CouchDB valida el nombre de la base de datos al conectar. El nombre no puede estar vacío.",
},
"Create or connect to database and continue": {
def: "Create or connect to database and continue",
es: "Crear o conectar a la base de datos y continuar",
},
"Create P2P remote": {
def: "Create P2P remote",
es: "Crear remoto P2P",
@@ -1433,7 +1430,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"dialog.yourLanguageAvailable": {
def: "Self-hosted LiveSync had translations for your language, so the %{Display language} setting was enabled.\n\nNote: Not all messages are translated. We are waiting for your contributions!\nNote 2: If you create an Issue, **please revert to Default** and then take screenshots, messages and logs. This can be done in the setting dialogue.\nMay you find it easy to use!",
es: "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a Predeterminado** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
es: "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste Idioma de visualización.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a Predeterminado** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
fr: "Self-hosted LiveSync dispose d'une traduction pour votre langue, le paramètre %{Display language} a donc été activé.\n\nNote : Tous les messages ne sont pas traduits. Nous attendons vos contributions !\nNote 2 : Si vous créez un ticket, **veuillez revenir à Par défaut** puis prendre des captures d'écran, messages et journaux. Cela peut être fait dans la boîte de dialogue des paramètres.\nBonne utilisation !",
he: "ל-Self-hosted LiveSync יש תרגום לשפתך, ולכן הגדרת %{Display language} הופעלה.\n\nהערה: לא כל ההודעות מתורגמות. אנחנו ממתינים לתרומותיך!\nהערה 2: אם אתה פותח Issue, **אנא חזור ל-%{lang-def}** ואז צלם צילומי מסך, הודעות ויומנים. ניתן לעשות זאת בדיאלוג ההגדרות.\nנקווה שתמצא/י את הפלאגין נוח לשימוש!",
ja: "Self-hosted LiveSync に設定されている言語の翻訳がありましたので、インターフェースの表示言語が適用されました。\n\n注意: 全てのメッセージは翻訳されていません。あなたの貢献をお待ちしています!\nGithubにIssueを作成する際には、 インターフェースの表示言語 を一旦 Default に戻してから、スクショやメッセージ、ログを収集してください。これは設定から変更できます。\n\n便利に使用できれば幸いです。",
@@ -2035,6 +2032,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "增大块大小",
"zh-tw": "擴大 chunk 大小",
},
"Enter a complete HTTP or HTTPS URL.": {
def: "Enter a complete HTTP or HTTPS URL.",
es: "Introduce una URL HTTP o HTTPS completa.",
},
"Enter a folder prefix (optional)": {
def: "Enter a folder prefix (optional)",
es: "Introduce un prefijo de carpeta (opcional)",
@@ -2530,6 +2531,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "해당 없는 항목 숨기기",
"zh-tw": "隱藏不適用的項目",
},
"Hide password": {
def: "Hide password",
es: "Ocultar contraseña",
},
"Higher (${local} > ${remote})": {
def: "Higher (${local} > ${remote})",
es: "Superior (${local} > ${remote})",
@@ -5369,7 +5374,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": {
def: "Configured synchronization mode: LiveSync",
es: "Modo de sincronización configurado: Sincronización en Vivo",
es: "Modo de sincronización configurado: Sincronización en vivo",
fr: "Mode de synchronisation configuré : LiveSync",
he: "מצב סנכרון שהוגדר: LiveSync",
ja: "設定された同期モード: LiveSync",
@@ -5981,7 +5986,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": {
def: "Test Database Connection",
es: "Probar Conexión de Base de Datos",
es: "Probar conexión de base de datos",
fr: "Tester la connexion à la base de données",
he: "בדוק חיבור למסד נתונים",
ja: "データベース接続テスト",
@@ -5992,7 +5997,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": {
def: "Validate Database Configuration",
es: "Validar Configuración de la Base de Datos",
es: "Validar configuración de la base de datos",
fr: "Valider la configuration de la base de données",
he: "אמת תצורת מסד נתונים",
ja: "データベース設定を検証",
@@ -6300,7 +6305,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.panelGeneralSettings": {
def: "General Settings",
es: "Configuraciones Generales",
es: "Configuraciones generales",
fr: "Paramètres généraux",
he: "הגדרות כלליות",
ja: "一般設定",
@@ -6311,7 +6316,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": {
def: "Privacy & Encryption",
es: "Privacidad y Cifrado",
es: "Privacidad y cifrado",
fr: "Confidentialité et chiffrement",
he: "פרטיות והצפנה",
ja: "プライバシーと暗号化",
@@ -6640,7 +6645,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.titleSyncSettings": {
def: "Sync Settings",
es: "Configuraciones de Sincronización",
es: "Configuraciones de sincronización",
fr: "Paramètres de synchronisation",
he: "הגדרות סנכרון",
ja: "同期設定",
@@ -8384,6 +8389,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "将设置保存到一个 Markdown 文件中。当新设置到达时,您将收到通知。您可以根据平台设置不同的文件 ",
"zh-tw": "將設定儲存到 Markdown 檔案中。有新設定送達時會通知你,可依平台設定不同的檔案。",
},
"Save without connecting": {
def: "Save without connecting",
es: "Guardar sin conectar",
},
"Saving will be performed forcefully after this number of seconds.": {
def: "Saving will be performed forcefully after this number of seconds.",
es: "Guardado forzado tras esta cantidad de segundos",
@@ -8395,6 +8404,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "在此秒数后将强制执行保存 ",
"zh-tw": "經過這個秒數後,會強制執行儲存。",
},
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
{
def: "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
es: "Guardar sin una prueba de conexión correcta conserva este perfil, pero la sincronización automática puede fallar hasta que se corrija la conexión.",
},
"Scan a QR Code (Recommended for mobile)": {
def: "Scan a QR Code (Recommended for mobile)",
es: "Escanear un código QR (recomendado para móviles)",
@@ -9414,6 +9428,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "仅显示通知",
"zh-tw": "僅顯示通知",
},
"Show password": {
def: "Show password",
es: "Mostrar contraseña",
},
"Show status as icons only": {
def: "Show status as icons only",
es: "Mostrar estado solo con íconos",
@@ -9773,6 +9791,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "目标模式",
"zh-tw": "目標模式",
},
"Test connection and save": {
def: "Test connection and save",
es: "Probar la conexión y guardar",
},
"Test Settings and Continue": {
def: "Test Settings and Continue",
es: "Probar los ajustes y continuar",
@@ -9998,6 +10020,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw":
"此功能可在裝置之間直接同步,無需伺服器;但同步時兩台裝置必須同時在線,且部分功能可能受限。網際網路連線僅用於訊號交換(偵測對端),不用於資料傳輸。",
},
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
{
def: "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
es: "Esta primera configuración consta de varios pasos breves, ya que confirma el cifrado, el método de conexión y qué dispositivo aporta los datos iniciales. Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.",
},
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": {
def: "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
es: "Esta es una opción avanzada para usuarios que no disponen de un URI o que desean configurar parámetros detallados。",
@@ -10024,6 +10051,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "这是最符合当前设计的同步方式,所有功能均可用。你需要事先部署好 CouchDB 实例。",
"zh-tw": "這是最符合目前設計的同步方式,所有功能皆可使用。你需要事先部署好 CouchDB 實例。",
},
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
{
def: "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
es: "Esta comprobación opcional usa la API interna de solicitudes de Obsidian y envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un servidor de confianza; puede requerir acceso de administrador.",
},
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": {
def: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
es: "Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar",
@@ -12684,6 +12716,20 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw":
"只有在特殊情況下才應執行此操作,例如伺服器資料已完全損毀、其他所有裝置上的變更都已不再需要,或資料庫大小相對於 Vault 大小已變得異常龐大時。",
},
"you wanted(Thank you)!": {
def: "you wanted(Thank you)!",
es: "tu solicitud (¡gracias!)",
},
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.":
{
es: "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
ja: "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
ko: "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
ru: "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
zh: "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"zh-tw":
"(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
},
"Compute revisions for chunks (Previous behaviour)": {
es: "Calcular revisiones para chunks (comportamiento anterior)",
},
+1 -1
View File
@@ -2,7 +2,7 @@
"(Active)": "(Aktiv)",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Leer lassen, um alle Dateien zu synchronisieren. Legen Sie einen Filter als regulären Ausdruck fest, um die zu synchronisierenden Dateien einzuschränken.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Wenn dies gesetzt ist, werden alle Änderungen an lokalen und Remote-Dateien übersprungen, die diesem Muster entsprechen.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie dieses Gerät als erstes Synchronisationsgerät einrichten.) Diese Option ist geeignet, wenn Sie LiveSync neu verwenden und von Grund auf einrichten möchten.",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Die folgenden verbundenen Geräte wurden erkannt:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Eine Setup-URI ist eine einzelne Zeichenfolge, die Ihre Serveradresse und Authentifizierungsdaten enthält. Wenn Ihre Serverinstallation eine URI erzeugt hat, bietet deren Verwendung eine einfache und sichere Konfiguration。",
+15 -4
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Obsolete) Use an old adapter for compatibility",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.": "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
"↑: Overwrite Remote": "↑: Overwrite Remote",
"↓: Overwrite Local": "↓: Overwrite Local",
@@ -46,8 +46,6 @@
"Active Remote Configuration": "Active Remote Configuration",
"Add default patterns": "Add default patterns",
"Add new connection": "Add new connection",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
"Advanced": "Advanced",
"Advanced Settings": "Advanced Settings",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.",
@@ -90,6 +88,7 @@
"Check": "Check",
"Check and convert non-path-obfuscated files": "Check and convert non-path-obfuscated files",
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.",
"Check server requirements": "Check server requirements",
"Checking connection... Please wait.": "Checking connection... Please wait.",
"Chunks": "Chunks",
"Close": "Close",
@@ -121,6 +120,7 @@
"Configure Remote": "Configure Remote",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure the same server information as your other devices again, manually, very advanced users only.",
"Connect": "Connect",
"Connect to existing database and continue": "Connect to existing database and continue",
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})",
"Connected:": "Connected:",
"Connection Method": "Connection Method",
@@ -134,6 +134,8 @@
"Copy Report to clipboard": "Copy Report to clipboard",
"CouchDB Configuration": "CouchDB Configuration",
"CouchDB Connection Tweak": "CouchDB Connection Tweak",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB validates the database name when you connect. The name must not be empty.",
"Create or connect to database and continue": "Create or connect to database and continue",
"Create P2P remote": "Create P2P remote",
"Cross-platform": "Cross-platform",
"Current adapter: {adapter}": "Current adapter: {adapter}",
@@ -236,6 +238,7 @@
"End-to-End Encryption": "End-to-End Encryption",
"Endpoint URL": "Endpoint URL",
"Enhance chunk size": "Enhance chunk size",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"Enter a folder prefix (optional)": "Enter a folder prefix (optional)",
"Enter Server Information": "Enter Server Information",
"Enter Setup URI": "Enter Setup URI",
@@ -303,6 +306,7 @@
"Hidden Files": "Hidden Files",
"Hide completely": "Hide completely",
"Hide not applicable items": "Hide not applicable items",
"Hide password": "Hide password",
"Higher (${local} > ${remote})": "Higher (${local} > ${remote})",
"Highlight diff": "Highlight diff",
"How to display network errors when the sync server is unreachable.": "How to display network errors when the sync server is unreachable.",
@@ -910,7 +914,9 @@
"Same or local only": "Same or local only",
"Save and Apply": "Save and Apply",
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.",
"Save without connecting": "Save without connecting",
"Saving will be performed forcefully after this number of seconds.": "Saving will be performed forcefully after this number of seconds.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.": "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"Scan a QR Code (Recommended for mobile)": "Scan a QR Code (Recommended for mobile)",
"Scan changes": "Scan changes",
"Scan changes on customization sync": "Scan changes on customization sync",
@@ -1019,6 +1025,7 @@
"Show history": "Show history",
"Show icon only": "Show icon only",
"Show only notifications": "Show only notifications",
"Show password": "Show password",
"Show status as icons only": "Show status as icons only",
"Show status icon instead of file warnings banner": "Show status icon instead of file warnings banner",
"Show status inside the editor": "Show status inside the editor",
@@ -1060,6 +1067,7 @@
"Syncing": "Syncing",
"Syncing...": "Syncing...",
"Target patterns": "Target patterns",
"Test connection and save": "Test connection and save",
"Test Settings and Continue": "Test Settings and Continue",
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.",
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.": "The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.",
@@ -1089,9 +1097,11 @@
"This device": "This device",
"This device name": "This device name",
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.",
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.": "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
"This password is used to encrypt the connection. Use something long enough.": "This password is used to encrypt the connection. Use something long enough.",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as",
@@ -1462,5 +1472,6 @@
"You are adding this device to an existing synchronisation setup.": "You are adding this device to an existing synchronisation setup.",
"You can configure in the Obsidian Plugin Settings.": "You can configure in the Obsidian Plugin Settings.",
"You should create a new synchronisation destination and rebuild your data there.": "You should create a new synchronisation destination and rebuild your data there.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size."
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.",
"you wanted(Thank you)!": "you wanted(Thank you)!"
}
+22 -11
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Obsoleto) Usar adaptador antiguo",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Déjelo vacío para sincronizar todos los archivos. Defina un filtro como expresión regular para limitar los archivos que se sincronizan.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Si se establece, se omitirá cualquier cambio en archivos locales y remotos que coincida con este patrón.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si está configurando este dispositivo como el primer dispositivo de sincronización). Esta opción es adecuada si es nuevo en LiveSync y desea configurarlo desde cero。",
"↑: Overwrite Remote": "↑: Sobrescribir remoto",
"↓: Overwrite Local": "↓: Sobrescribir local",
@@ -46,8 +46,6 @@
"Active Remote Configuration": "Configuración remota activa",
"Add default patterns": "Añadir patrones predeterminados",
"Add new connection": "Añadir conexión",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
"Advanced": "Avanzado",
"Advanced Settings": "Ajustes avanzados",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "Tras reiniciar, los datos de este dispositivo se subirán al servidor como «copia maestra». Ten en cuenta que cualquier dato no deseado que haya ahora en el servidor se sobrescribirá por completo.",
@@ -90,6 +88,7 @@
"Check": "Comprobar",
"Check and convert non-path-obfuscated files": "Comprobar y convertir archivos sin ofuscación de ruta",
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Comprueba los documentos que aún no se hayan convertido a identificadores con ruta ofuscada y conviértelos si es necesario.",
"Check server requirements": "Comprobar los requisitos del servidor",
"Checking connection... Please wait.": "Comprobando la conexión... Espera un momento.",
"Chunks": "Fragmentos (chunks)",
"Close": "Cerrar",
@@ -122,6 +121,7 @@
"Configure Remote": "Configurar remoto",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure manualmente la misma información del servidor que en sus otros dispositivos. Solo para usuarios muy avanzados。",
"Connect": "Conectar",
"Connect to existing database and continue": "Conectar a la base de datos existente y continuar",
"Connected to Signaling Server (as Peer ID: ${peerId})": "Conectado al servidor de señalización (como ID de par: ${peerId})",
"Connected:": "Conectadas:",
"Connection Method": "Método de conexión",
@@ -135,6 +135,8 @@
"Copy Report to clipboard": "Copiar el informe al portapapeles",
"CouchDB Configuration": "Configuración de CouchDB",
"CouchDB Connection Tweak": "Ajustes de conexión de CouchDB",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB valida el nombre de la base de datos al conectar. El nombre no puede estar vacío.",
"Create or connect to database and continue": "Crear o conectar a la base de datos y continuar",
"Create P2P remote": "Crear remoto P2P",
"Cross-platform": "Multiplataforma",
"Current adapter: {adapter}": "Adaptador actual: {adapter}",
@@ -177,7 +179,7 @@
"Device Setup Method": "Método de configuración del dispositivo",
"Devices:": "Dispositivos:",
"Diagnostic RTCPeerConnection is enabled": "El RTCPeerConnection de diagnóstico está habilitado",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display Language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable.btnRevertToDefault": "Mantener %{lang-def}",
"dialog.yourLanguageAvailable.Title": " ¡Hay traducción disponible!",
"Diff": "Diferencias",
@@ -237,6 +239,7 @@
"End-to-End Encryption": "Cifrado de extremo a extremo",
"Endpoint URL": "URL del endpoint",
"Enhance chunk size": "Mejorar tamaño de chunks",
"Enter a complete HTTP or HTTPS URL.": "Introduce una URL HTTP o HTTPS completa.",
"Enter a folder prefix (optional)": "Introduce un prefijo de carpeta (opcional)",
"Enter Server Information": "Introducir información del servidor",
"Enter Setup URI": "Introducir el Setup URI",
@@ -304,6 +307,7 @@
"Hidden Files": "Archivos ocultos",
"Hide completely": "Ocultar por completo",
"Hide not applicable items": "Ocultar elementos no aplicables",
"Hide password": "Ocultar contraseña",
"Higher (${local} > ${remote})": "Superior (${local} > ${remote})",
"Highlight diff": "Resaltar las diferencias",
"How to display network errors when the sync server is unreachable.": "Cómo mostrar los errores de red cuando el servidor de sincronización no está disponible.",
@@ -593,7 +597,7 @@
"obsidianLiveSyncSettingTab.logCheckingDbConfig": "Verificando la configuración de la base de datos",
"obsidianLiveSyncSettingTab.logCheckPassphraseFailed": "ERROR: Error al comprobar la frase de contraseña con el servidor remoto:\n${db}.",
"obsidianLiveSyncSettingTab.logConfiguredDisabled": "Modo de sincronización configurado: DESACTIVADO",
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": "Modo de sincronización configurado: Sincronización en Vivo",
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": "Modo de sincronización configurado: Sincronización en vivo",
"obsidianLiveSyncSettingTab.logConfiguredPeriodic": "Modo de sincronización configurado: Periódico",
"obsidianLiveSyncSettingTab.logCouchDbConfigFail": "Configuración de CouchDB: ${title} falló",
"obsidianLiveSyncSettingTab.logCouchDbConfigSet": "Configuración de CouchDB: ${title} -> Establecer ${key} en ${value}",
@@ -648,8 +652,8 @@
"obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": "Sincronización de archivos ocultos",
"obsidianLiveSyncSettingTab.nameManualSetup": "Configuración manual",
"obsidianLiveSyncSettingTab.nameTestConnection": "Probar conexión",
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": "Probar Conexión de Base de Datos",
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": "Validar Configuración de la Base de Datos",
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": "Probar conexión de base de datos",
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": "Validar configuración de la base de datos",
"obsidianLiveSyncSettingTab.okAdminPrivileges": "✔ Tienes privilegios de administrador.",
"obsidianLiveSyncSettingTab.okCorsCredentials": "✔ cors.credentials está correcto.",
"obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": "CORS credenciales OK",
@@ -677,8 +681,8 @@
"obsidianLiveSyncSettingTab.optionRebuildBoth": "Reconstructuir ambos desde este dispositivo",
"obsidianLiveSyncSettingTab.optionSaveOnlySettings": "(Peligro) Guardar solo configuración",
"obsidianLiveSyncSettingTab.panelChangeLog": "Registro de cambios",
"obsidianLiveSyncSettingTab.panelGeneralSettings": "Configuraciones Generales",
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": "Privacidad y Cifrado",
"obsidianLiveSyncSettingTab.panelGeneralSettings": "Configuraciones generales",
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": "Privacidad y cifrado",
"obsidianLiveSyncSettingTab.panelRemoteConfiguration": "Configuración remota",
"obsidianLiveSyncSettingTab.panelSetup": "Configuración",
"obsidianLiveSyncSettingTab.serverVersion": "Información del servidor: ${info}",
@@ -706,7 +710,7 @@
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": "Para configurar otros dispositivos",
"obsidianLiveSyncSettingTab.titleSynchronizationMethod": "Método de sincronización",
"obsidianLiveSyncSettingTab.titleSynchronizationPreset": "Preestablecimiento de sincronización",
"obsidianLiveSyncSettingTab.titleSyncSettings": "Configuraciones de Sincronización",
"obsidianLiveSyncSettingTab.titleSyncSettings": "Configuraciones de sincronización",
"obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown": "Configuración de sincronización a través de Markdown",
"obsidianLiveSyncSettingTab.titleUpdateThinning": "Actualización de adelgazamiento",
"obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": "⚠ El origen de CORS no coincide: {from}->{to}",
@@ -903,7 +907,9 @@
"Same or local only": "Igual o solo local",
"Save and Apply": "Guardar y aplicar",
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "Guardar configuración en archivo markdown. Se notificarán nuevos ajustes. Puede definir diferentes archivos por plataforma",
"Save without connecting": "Guardar sin conectar",
"Saving will be performed forcefully after this number of seconds.": "Guardado forzado tras esta cantidad de segundos",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.": "Guardar sin una prueba de conexión correcta conserva este perfil, pero la sincronización automática puede fallar hasta que se corrija la conexión.",
"Scan a QR Code (Recommended for mobile)": "Escanear un código QR (recomendado para móviles)",
"Scan changes": "Buscar cambios",
"Scan changes on customization sync": "Escanear cambios en sincronización de personalización",
@@ -1053,6 +1059,7 @@
"Show history": "Mostrar el historial",
"Show icon only": "Mostrar solo el icono",
"Show only notifications": "Mostrar solo notificaciones",
"Show password": "Mostrar contraseña",
"Show status as icons only": "Mostrar estado solo con íconos",
"Show status icon instead of file warnings banner": "Mostrar icono de estado en lugar del banner de advertencia de archivos",
"Show status inside the editor": "Mostrar estado dentro del editor",
@@ -1094,6 +1101,7 @@
"Syncing": "Sincronización",
"Syncing...": "Sincronizando...",
"Target patterns": "Patrones objetivo",
"Test connection and save": "Probar la conexión y guardar",
"Test Settings and Continue": "Probar los ajustes y continuar",
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Solo pruebas - Resolver conflictos sincronizando copias nuevas (puede sobrescribir modificaciones)",
"The connection to the server has been configured successfully. As the next step,": "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
@@ -1122,9 +1130,11 @@
"This device": "Este dispositivo",
"This device name": "Nombre de este dispositivo",
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "Esta función permite la sincronización directa entre dispositivos. No requiere servidor, pero ambos dispositivos deben estar en línea al mismo tiempo para que la sincronización se produzca, y algunas funciones pueden ser limitadas. La conexión a Internet solo se necesita para la señalización (detección de pares), no para la transferencia de datos。",
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "Esta primera configuración consta de varios pasos breves, ya que confirma el cifrado, el método de conexión y qué dispositivo aporta los datos iniciales. Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "Esta es una opción avanzada para usuarios que no disponen de un URI o que desean configurar parámetros detallados。",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "Esta es una operación extremadamente potente. Te recomendamos encarecidamente copiar la carpeta de tu Vault a un lugar seguro.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "Este es el método de sincronización más adecuado para el diseño. Todas las funciones están disponibles. Debe tener configurada una instancia de CouchDB。",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.": "Esta comprobación opcional usa la API interna de solicitudes de Obsidian y envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un servidor de confianza; puede requerir acceso de administrador.",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar",
"This password is used to encrypt the connection. Use something long enough.": "Esta contraseña se usa para cifrar la conexión. Usa algo suficientemente largo.",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "Este procedimiento eliminará primero todos los datos de sincronización existentes en el servidor. A continuación, los datos del servidor se reconstruirán por completo usando el estado actual del Vault de este dispositivo (incluida su base de datos local) como",
@@ -1473,5 +1483,6 @@
"You are adding this device to an existing synchronisation setup.": "Está añadiendo este dispositivo a una configuración de sincronización existente。",
"You can configure in the Obsidian Plugin Settings.": "Puedes configurarlo en los ajustes del complemento de Obsidian.",
"You should create a new synchronisation destination and rebuild your data there.": "Deberías crear un nuevo destino de sincronización y reconstruir allí tus datos.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "Solo deberías realizar esta operación en circunstancias excepcionales: cuando los datos del servidor estén completamente corruptos, cuando ya no necesites los cambios de los demás dispositivos o cuando el tamaño de la base de datos sea inusualmente grande respecto al del Vault."
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "Solo deberías realizar esta operación en circunstancias excepcionales: cuando los datos del servidor estén completamente corruptos, cuando ya no necesites los cambios de los demás dispositivos o cuando el tamaño de la base de datos sea inusualmente grande respecto al del Vault.",
"you wanted(Thank you)!": "tu solicitud (¡gracias!)"
}
+1 -1
View File
@@ -10,7 +10,7 @@
"(Obsolete) Use an old adapter for compatibility": "(廃止済み)古いアダプターを互換性のために利用",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正規表現)空欄で全ファイルを同期します。正規表現を指定すると、同期対象のファイルを絞り込めます。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正規表現)設定すると、これに一致するローカル/リモートファイルの変更はすべてスキップされます。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(この端末を最初の同期端末として設定する場合に選択してください。)LiveSync を初めて利用し、最初から設定したい場合に適しています。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 次の接続済みデバイスが検出されました:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI は、サーバーアドレスと認証情報を含む 1 本の文字列です。サーバーのインストールスクリプトで生成された URI がある場合は、それを使うと簡単かつ安全に設定できます。",
+1 -3
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(사용 중단) 호환성을 위해 이전 어댑터 사용",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.",
"↑: Overwrite Remote": "↑: 원격 덮어쓰기",
"↓: Overwrite Local": "↓: 로컬 덮어쓰기",
@@ -46,8 +46,6 @@
"Active Remote Configuration": "활성 원격 구성",
"Add default patterns": "기본 패턴 추가",
"Add new connection": "연결 추가",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.",
"Advanced": "고급",
"Advanced Settings": "고급 설정",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.",
+1 -1
View File
@@ -11,7 +11,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Устарело) Использовать старый адаптер для совместимости",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Оставьте пустым, чтобы синхронизировать все файлы. Укажите регулярное выражение, чтобы ограничить синхронизируемые файлы.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Если задано, любые изменения локальных и удалённых файлов, соответствующих этому шаблону, будут пропускаться.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если настраиваете это устройство как первое устройство синхронизации.) Он подходит, если вы впервые используете LiveSync и хотите настроить всё с нуля。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Обнаружены следующие подключённые устройства:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI — это одна строка текста, содержащая адрес сервера и данные аутентификации. Если URI был создан скриптом установки сервера, его использование обеспечивает простую и безопасную настройку。",
+1 -3
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(已淘汰)使用舊版轉接器以維持相容性",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正則表示式)留空即同步所有檔案。設定正則表示式可限制要同步的檔案。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正則表示式)若已設定,所有符合此模式的本機與遠端檔案變更都會被略過。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在將此裝置設定為第一台同步裝置,請選擇此項。)此選項適合初次使用 LiveSync,並希望從頭開始設定的使用者。",
"↑: Overwrite Remote": "↑:覆寫遠端",
"↓: Overwrite Local": "↓:覆寫本機",
@@ -46,8 +46,6 @@
"Active Remote Configuration": "目前啟用的遠端設定",
"Add default patterns": "新增預設模式",
"Add new connection": "新增連線",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。",
"Advanced": "進階",
"Advanced Settings": "進階設定",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "重新啟動後,此裝置上的資料將以「主要複本」的形式上傳到伺服器。請注意,伺服器上任何非預期的現有資料都會被完全覆寫。",
+1 -1
View File
@@ -10,7 +10,7 @@
"(Obsolete) Use an old adapter for compatibility": "(已弃用)为兼容性使用旧适配器",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正则表达式)留空表示同步所有文件。可设置正则表达式来限制需要同步的文件。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正则表达式)如果已设置,则所有匹配此模式的本地和远端文件变更都会被跳过。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在将此设备配置为第一台同步设备,请选择此项。)此选项适合初次使用 LiveSync,并希望从头开始配置的用户。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 已检测到以下已连接设备:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI 是一段包含服务器地址与认证信息的文本。如果服务器安装脚本已经生成了 URI,使用它可以更简单且更安全地完成配置。",
+1 -1
View File
@@ -283,7 +283,7 @@ xxhash64 (Fastest): xxhash64 (am schnellsten)
"I am setting this up for the first time": "Ich richte dies zum ersten Mal ein"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie dieses Gerät als erstes Synchronisationsgerät einrichten.) Diese Option ist geeignet, wenn Sie LiveSync neu verwenden und von Grund auf einrichten möchten."
"I am adding a device to an existing synchronisation setup": "Ich füge ein Gerät zu einer bestehenden Synchronisationseinrichtung hinzu"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten."
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten."
"Yes, I want to set up a new synchronisation": "Ja, ich möchte eine neue Synchronisation einrichten"
"Yes, I want to add this device to my existing synchronisation": "Ja, ich möchte dieses Gerät zu meiner bestehenden Synchronisation hinzufügen"
"No, please take me back": "Nein, bitte zurück"
+24 -7
View File
@@ -58,12 +58,6 @@ Activate: Activate
Active Remote Configuration: Active Remote Configuration
Add default patterns: Add default patterns
Add new connection: Add new connection
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
AddOn Module (ConfigSync) has not been loaded. This is very unexpected
situation. Please report this issue.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected
situation. Please report this issue.
Advanced: Advanced
Advanced Settings: Advanced Settings
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
@@ -129,6 +123,7 @@ Check and convert non-path-obfuscated files: Check and convert non-path-obfuscat
Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.:
Check for documents that have not been converted to path-obfuscated IDs and
convert them if necessary.
Check server requirements: Check server requirements
Checking connection... Please wait.: Checking connection... Please wait.
Chunks: Chunks
Close: Close
@@ -158,6 +153,7 @@ Configure And Change Remote: Configure And Change Remote
Configure E2EE: Configure E2EE
Configure Remote: Configure Remote
Connect: Connect
Connect to existing database and continue: Connect to existing database and continue
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})"
"Connected:": "Connected:"
Connection Settings: Connection Settings
@@ -167,7 +163,11 @@ Copy: Copy
Copy Report to clipboard: Copy Report to clipboard
CouchDB Configuration: CouchDB Configuration
CouchDB Connection Tweak: CouchDB Connection Tweak
CouchDB validates the database name when you connect. The name must not be empty.:
CouchDB validates the database name when you connect. The name must not be
empty.
Create P2P remote: Create P2P remote
Create or connect to database and continue: Create or connect to database and continue
Cross-platform: Cross-platform
"Current adapter: {adapter}": "Current adapter: {adapter}"
Custom Headers: Custom Headers
@@ -340,6 +340,7 @@ Encryption phassphrase. If changed, you should overwrite the server's database w
End-to-End Encryption: End-to-End Encryption
Endpoint URL: Endpoint URL
Enhance chunk size: Enhance chunk size
Enter a complete HTTP or HTTPS URL.: Enter a complete HTTP or HTTPS URL.
Enter a folder prefix (optional): Enter a folder prefix (optional)
Enter Setup URI: Enter Setup URI
Enter TURN credential: Enter TURN credential
@@ -402,6 +403,7 @@ Hidden file synchronization have been temporarily disabled. Please enable them a
Hidden Files: Hidden Files
Hide completely: Hide completely
Hide not applicable items: Hide not applicable items
Hide password: Hide password
Higher (${local} > ${remote}): Higher (${local} > ${remote})
Highlight diff: Highlight diff
How to display network errors when the sync server is unreachable.: How to display network errors when the sync server is unreachable.
@@ -1488,7 +1490,11 @@ Save and Apply: Save and Apply
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.:
Save settings to a markdown file. You will be notified when new settings
arrive. You can set different files by the platform.
Save without connecting: Save without connecting
Saving will be performed forcefully after this number of seconds.: Saving will be performed forcefully after this number of seconds.
Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.:
Saving without a successful connection test keeps this profile, but
automatic synchronisation may fail until the connection is corrected.
Scan changes: Scan changes
Scan changes on customization sync: Scan changes on customization sync
Scan customization automatically: Scan customization automatically
@@ -1750,6 +1756,7 @@ Sync: Sync
Sync once: Sync once
Syncing...: Syncing...
Test Settings and Continue: Test Settings and Continue
Test connection and save: Test connection and save
The connection to the server has been configured successfully. As the next step,:
The connection to the server has been configured successfully. As the next
step,
@@ -1800,6 +1807,7 @@ Show full banner: Show full banner
Show history: Show history
Show icon only: Show icon only
Show only notifications: Show only notifications
Show password: Show password
Show status as icons only: Show status as icons only
Show status icon instead of file warnings banner: Show status icon instead of file warnings banner
Show status inside the editor: Show status inside the editor
@@ -1876,9 +1884,17 @@ This can isolate your connections between devices. Use the same Room ID for the
the same devices.
This device: This device
This device name: This device name
This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.:
This first setup has several short steps because it confirms encryption,
the connection method, and which device provides the initial data. Once it
is complete, additional devices can reuse a Setup URI.
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
This is an extremely powerful operation. We strongly recommend that you copy
your Vault folder to a safe location.
This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.:
This optional check uses Obsidian's internal request API and sends the
credentials above to the CouchDB server. Use it only with a server you
trust; administrator access may be required.
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.:
This passphrase will not be copied to another device. It will be set to
`Default` until you configure it again.
@@ -2071,7 +2087,7 @@ xxhash64 (Fastest): xxhash64 (Fastest)
"I am setting this up for the first time": "I am setting this up for the first time"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
"I am adding a device to an existing synchronisation setup": "I am adding a device to an existing synchronisation setup"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.": "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation."
"Yes, I want to set up a new synchronisation": "Yes, I want to set up a new synchronisation"
"Yes, I want to add this device to my existing synchronisation": "Yes, I want to add this device to my existing synchronisation"
"No, please take me back": "No, please take me back"
@@ -2419,6 +2435,7 @@ Ui:
Title: Choose a synchronisation remote
You can configure in the Obsidian Plugin Settings.: You can configure in the Obsidian Plugin Settings.
"you wanted(Thank you)!": "you wanted(Thank you)!"
You should create a new synchronisation destination and rebuild your data there.:
You should create a new synchronisation destination and rebuild your data
+31 -14
View File
@@ -57,12 +57,6 @@ Activate: Activar
Active Remote Configuration: Configuración remota activa
Add default patterns: Añadir patrones predeterminados
Add new connection: Añadir conexión
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy
inesperada. Informa de este problema.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es
muy inesperada. Informa de este problema.
Advanced: Avanzado
Advanced Settings: Ajustes avanzados
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
@@ -132,6 +126,7 @@ Check and convert non-path-obfuscated files: Comprobar y convertir archivos sin
Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.:
Comprueba los documentos que aún no se hayan convertido a identificadores con
ruta ofuscada y conviértelos si es necesario.
Check server requirements: Comprobar los requisitos del servidor
Checking connection... Please wait.: Comprobando la conexión... Espera un momento.
Chunks: Fragmentos (chunks)
Close: Cerrar
@@ -166,6 +161,7 @@ Configure And Change Remote: Configurar y cambiar remoto
Configure E2EE: Configurar E2EE
Configure Remote: Configurar remoto
Connect: Conectar
Connect to existing database and continue: Conectar a la base de datos existente y continuar
"Connected to Signaling Server (as Peer ID: ${peerId})": "Conectado al servidor de señalización (como ID de par: ${peerId})"
"Connected:": "Conectadas:"
Connection Settings: Ajustes de conexión
@@ -176,6 +172,10 @@ Copy Report to clipboard: Copiar el informe al portapapeles
CouchDB Configuration: Configuración de CouchDB
CouchDB Connection Tweak: Ajustes de conexión de CouchDB
Create P2P remote: Crear remoto P2P
CouchDB validates the database name when you connect. The name must not be empty.:
CouchDB valida el nombre de la base de datos al conectar. El nombre no puede
estar vacío.
Create or connect to database and continue: Crear o conectar a la base de datos y continuar
Cross-platform: Multiplataforma
"Current adapter: {adapter}": "Adaptador actual: {adapter}"
Custom Headers: Encabezados personalizados
@@ -224,7 +224,7 @@ dialog:
yourLanguageAvailable:
_value: >-
Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha
activado el ajuste %{Display language}.
activado el ajuste %{Display Language}.
Nota: no todos los mensajes están traducidos. ¡Esperamos tus
@@ -345,6 +345,7 @@ Encryption phassphrase. If changed, you should overwrite the server's database w
End-to-End Encryption: Cifrado de extremo a extremo
Endpoint URL: URL del endpoint
Enhance chunk size: Mejorar tamaño de chunks
Enter a complete HTTP or HTTPS URL.: Introduce una URL HTTP o HTTPS completa.
Enter a folder prefix (optional): Introduce un prefijo de carpeta (opcional)
Enter Setup URI: Introducir el Setup URI
Enter TURN credential: Introduce la credencial de TURN
@@ -425,6 +426,7 @@ Hidden file synchronization have been temporarily disabled. Please enable them a
Hidden Files: Archivos ocultos
Hide completely: Ocultar por completo
Hide not applicable items: Ocultar elementos no aplicables
Hide password: Ocultar contraseña
Higher (${local} > ${remote}): Superior (${local} > ${remote})
Highlight diff: Resaltar las diferencias
How to display network errors when the sync server is unreachable.:
@@ -1518,7 +1520,7 @@ obsidianLiveSyncSettingTab:
ERROR: Error al comprobar la frase de contraseña con el servidor remoto:
${db}.
logConfiguredDisabled: "Modo de sincronización configurado: DESACTIVADO"
logConfiguredLiveSync: "Modo de sincronización configurado: Sincronización en Vivo"
logConfiguredLiveSync: "Modo de sincronización configurado: Sincronización en vivo"
logConfiguredPeriodic: "Modo de sincronización configurado: Periódico"
logCouchDbConfigFail: "Configuración de CouchDB: ${title} falló"
logCouchDbConfigSet: "Configuración de CouchDB: ${title} -> Establecer ${key} en ${value}"
@@ -1656,8 +1658,8 @@ obsidianLiveSyncSettingTab:
nameHiddenFileSynchronization: Sincronización de archivos ocultos
nameManualSetup: Configuración manual
nameTestConnection: Probar conexión
nameTestDatabaseConnection: Probar Conexión de Base de Datos
nameValidateDatabaseConfig: Validar Configuración de la Base de Datos
nameTestDatabaseConnection: Probar conexión de base de datos
nameValidateDatabaseConfig: Validar configuración de la base de datos
okAdminPrivileges: ✔ Tienes privilegios de administrador.
okCorsCredentials: ✔ cors.credentials está correcto.
okCorsCredentialsForOrigin: CORS credenciales OK
@@ -1684,8 +1686,8 @@ obsidianLiveSyncSettingTab:
optionRebuildBoth: Reconstructuir ambos desde este dispositivo
optionSaveOnlySettings: (Peligro) Guardar solo configuración
panelChangeLog: Registro de cambios
panelGeneralSettings: Configuraciones Generales
panelPrivacyEncryption: Privacidad y Cifrado
panelGeneralSettings: Configuraciones generales
panelPrivacyEncryption: Privacidad y cifrado
panelRemoteConfiguration: Configuración remota
panelSetup: Configuración
titleAppearance: Apariencia
@@ -1711,7 +1713,7 @@ obsidianLiveSyncSettingTab:
titleSetupOtherDevices: Para configurar otros dispositivos
titleSynchronizationMethod: Método de sincronización
titleSynchronizationPreset: Preestablecimiento de sincronización
titleSyncSettings: Configuraciones de Sincronización
titleSyncSettings: Configuraciones de sincronización
titleSyncSettingsViaMarkdown: Configuración de sincronización a través de Markdown
titleUpdateThinning: Actualización de adelgazamiento
warnCorsOriginUnmatched: "⚠ El origen de CORS no coincide: {from}->{to}"
@@ -1804,7 +1806,11 @@ Restore or reconstruct local database from remote.: Restaura o reconstruye la ba
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.:
Guardar configuración en archivo markdown. Se notificarán nuevos ajustes.
Puede definir diferentes archivos por plataforma
Save without connecting: Guardar sin conectar
Saving will be performed forcefully after this number of seconds.: Guardado forzado tras esta cantidad de segundos
Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.:
Guardar sin una prueba de conexión correcta conserva este perfil, pero la
sincronización automática puede fallar hasta que se corrija la conexión.
Scan changes on customization sync: Escanear cambios en sincronización de personalización
Scan customization automatically: Escanear personalización automáticamente
Scan customization before replicating.: Escanear personalización antes de replicar
@@ -1910,6 +1916,7 @@ Show full banner: Mostrar banner completo
Show history: Mostrar el historial
Show icon only: Mostrar solo el icono
Show only notifications: Mostrar solo notificaciones
Show password: Mostrar contraseña
Show status as icons only: Mostrar estado solo con íconos
Show status icon instead of file warnings banner: Mostrar icono de estado en lugar del banner de advertencia de archivos
Show status inside the editor: Mostrar estado dentro del editor
@@ -1960,6 +1967,7 @@ Syncing:
"": Sincronizando...
Target patterns: Patrones objetivo
Test Settings and Continue: Probar los ajustes y continuar
Test connection and save: Probar la conexión y guardar
Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.:
Solo pruebas - Resolver conflictos sincronizando copias nuevas (puede
sobrescribir modificaciones)
@@ -2022,9 +2030,17 @@ This can isolate your connections between devices. Use the same Room ID for the
para los mismos dispositivos.
This device: Este dispositivo
This device name: Nombre de este dispositivo
This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.:
Esta primera configuración consta de varios pasos breves, ya que confirma el
cifrado, el método de conexión y qué dispositivo aporta los datos iniciales.
Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
Esta es una operación extremadamente potente. Te recomendamos encarecidamente
copiar la carpeta de tu Vault a un lugar seguro.
This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.:
Esta comprobación opcional usa la API interna de solicitudes de Obsidian y
envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un
servidor de confianza; puede requerir acceso de administrador.
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.: Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar
This password is used to encrypt the connection. Use something long enough.: Esta contraseña se usa para cifrar la conexión. Usa algo suficientemente largo.
This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as:
@@ -2610,7 +2626,7 @@ xxhash64 (Fastest): xxhash64 (el más rápido)
"I am adding a device to an existing synchronisation setup":
"Estoy agregando un dispositivo a una configuración de sincronización
existente"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.":
"(Seleccione esto si ya utiliza la sincronización en otro ordenador o
teléfono). Esta opción es adecuada si desea añadir este dispositivo a una
configuración de LiveSync existente。"
@@ -2666,6 +2682,7 @@ xxhash64 (Fastest): xxhash64 (el más rápido)
limitadas. La conexión a Internet solo se necesita para la señalización
(detección de pares), no para la transferencia de datos。"
You can configure in the Obsidian Plugin Settings.: Puedes configurarlo en los ajustes del complemento de Obsidian.
"you wanted(Thank you)!": "tu solicitud (¡gracias!)"
You should create a new synchronisation destination and rebuild your data there.: Deberías crear un nuevo destino de sincronización y reconstruir allí tus datos.
You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.:
"Solo deberías realizar esta operación en circunstancias excepcionales: cuando
+1 -1
View File
@@ -1186,7 +1186,7 @@ The minimum interval for automatic synchronisation on event.: イベント発生
"I am setting this up for the first time": "はじめて設定します"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(この端末を最初の同期端末として設定する場合に選択してください。)LiveSync を初めて利用し、最初から設定したい場合に適しています。"
"I am adding a device to an existing synchronisation setup": "既存の同期構成に端末を追加します"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。"
"Yes, I want to set up a new synchronisation": "はい、新しい同期を設定します"
"Yes, I want to add this device to my existing synchronisation": "はい、この端末を既存の同期に追加します"
"No, please take me back": "いいえ、前に戻ります"
+1 -3
View File
@@ -14,7 +14,7 @@
(Obsolete) Use an old adapter for compatibility: (사용 중단) 호환성을 위해 이전 어댑터 사용
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.: (정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.: (정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.
(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.: (이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.
"↑: Overwrite Remote": "↑: 원격 덮어쓰기"
"↓: Overwrite Local": "↓: 로컬 덮어쓰기"
@@ -47,8 +47,6 @@ Activate: 활성화
Active Remote Configuration: 활성 원격 구성
Add default patterns: 기본 패턴 추가
Add new connection: 연결 추가
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(ConfigSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 애드온 모듈(HiddenFileSync)이 로드되지 않았습니다. 매우 예기치 못한 상황입니다. 이 문제를 신고해 주세요.
Advanced: 고급
Advanced Settings: 고급 설정
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.: 재시작하면 이 기기의 데이터가 '원본'으로서 서버에 업로드됩니다. 현재 서버에 있는 의도치 않은 데이터는 모두 완전히 덮어써진다는 점에 유의해 주세요.
+1 -1
View File
@@ -1058,7 +1058,7 @@ xxhash64 (Fastest): xxhash64 (самый быстрый)
"I am setting this up for the first time": "Я настраиваю это впервые"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если настраиваете это устройство как первое устройство синхронизации.) Он подходит, если вы впервые используете LiveSync и хотите настроить всё с нуля。"
"I am adding a device to an existing synchronisation setup": "Я добавляю устройство к существующей настройке синхронизации"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。"
"Yes, I want to set up a new synchronisation": "Да, я хочу настроить новую синхронизацию"
"Yes, I want to add this device to my existing synchronisation": "Да, я хочу добавить это устройство к существующей синхронизации"
"No, please take me back": "Нет, верните меня назад"
+1 -3
View File
@@ -139,8 +139,6 @@ username: 使用者名稱
"↑: Overwrite Remote": ↑:覆寫遠端
"↓: Overwrite Local": ↓:覆寫本機
"⇅: Use newer": ⇅:使用較新版本
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.: 附加模組(ConfigSync)尚未載入。這是非常異常的情況,請回報此問題。
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.: 附加模組(HiddenFileSync)尚未載入。這是非常異常的情況,請回報此問題。
All the same or non-existent: 全部相同或不存在
Apply All Selected: 套用所有已選取項目
Automatic: 自動
@@ -1667,7 +1665,7 @@ xxhash64 (Fastest): xxhash64(最快)
"I am setting this up for the first time": "我是第一次進行設定"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在將此裝置設定為第一台同步裝置,請選擇此項。)此選項適合初次使用 LiveSync,並希望從頭開始設定的使用者。"
"I am adding a device to an existing synchronisation setup": "我要將裝置加入既有同步設定"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。"
"Yes, I want to set up a new synchronisation": "是的,我要設定新的同步"
"Yes, I want to add this device to my existing synchronisation": "是的,我要把這台裝置加入既有同步"
"No, please take me back": "不,返回上一步"
+1 -1
View File
@@ -1568,7 +1568,7 @@ xxhash64 (Fastest): xxhash64(最快)
"I am setting this up for the first time": "我是第一次进行设置"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在将此设备配置为第一台同步设备,请选择此项。)此选项适合初次使用 LiveSync,并希望从头开始配置的用户。"
"I am adding a device to an existing synchronisation setup": "我要将设备加入现有同步配置"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。"
"Yes, I want to set up a new synchronisation": "是的,我要配置新的同步"
"Yes, I want to add this device to my existing synchronisation": "是的,我要把这台设备加入现有同步"
"No, please take me back": "不,返回上一步"
@@ -1,113 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { services: unknown };
get services() {
return this.core.services;
}
},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { cancelTask } from "@/common/utils.ts";
import { ConfigSync } from "./CmdConfigSync";
describe("ConfigSync commands", () => {
it("shows the Customisation Sync command only whilst the feature is enabled", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
usePluginSync: false,
};
const showPluginSyncModal = vi.fn();
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
settings,
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
},
},
addRibbonIcon: vi.fn(() => ({
addClass: vi.fn(),
})),
showPluginSyncModal,
});
configSync.onload();
const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex");
expect(command?.checkCallback?.(true)).toBe(false);
settings.usePluginSync = true;
expect(command?.checkCallback?.(true)).toBe(true);
expect(command?.checkCallback?.(false)).toBe(true);
expect(showPluginSyncModal).toHaveBeenCalledOnce();
});
it("cancels the pending configuration Notice before releasing its owned UI", () => {
const notices = { hide: vi.fn() };
const periodicPluginSweepProcessor = { disable: vi.fn() };
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
services: {
context: { notices },
},
},
periodicPluginSweepProcessor,
});
configSync.onunload();
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
expect(notices.hide).toHaveBeenCalledWith("config-sync:updated-configuration");
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
});
});
File diff suppressed because it is too large Load Diff
+11 -31
View File
@@ -1,15 +1,9 @@
<script lang="ts">
import {
ConfigSync,
PluginDataExDisplayV2,
type IPluginDataExDisplay,
type PluginDataExFile,
} from "./CmdConfigSync.ts";
import type { CustomisationSyncDialogView, IPluginDataExDisplay } from "./customisationSyncView.ts";
import type { PluginDataExFile } from "./customisationSyncCodec.ts";
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { type FilePath, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { getDocData, timeDeltaToHumanReadable, unique } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type ObsidianLiveSyncPlugin from "@/main";
// import { askString } from "../../common/utils";
import { Menu } from "@/deps.ts";
import { $msg as translateMessage } from "@/common/translation";
@@ -28,15 +22,9 @@
) => Promise<boolean>;
export let deleteData: (data: IPluginDataExDisplay) => Promise<boolean>;
export let hidden: boolean;
export let plugin: ObsidianLiveSyncPlugin;
export let customisationSync: CustomisationSyncDialogView;
export let isMaintenanceMode: boolean = false;
export let isFlagged: boolean = false;
$: core = plugin.core;
const addOn = plugin.core.getAddOn<ConfigSync>(ConfigSync.name)!;
if (!addOn) {
Logger(`Could not load the add-on ${ConfigSync.name}`, LOG_LEVEL_INFO);
throw new Error(`Could not load the add-on ${ConfigSync.name}`);
}
export let selected = "";
let freshness = "";
@@ -263,7 +251,7 @@
const local = list.find((e) => e.term == thisTerm);
const selectedItem = list.find((e) => e.term == selected);
if (selectedItem && (await applyData(selectedItem))) {
addOn.updatePluginList(true, local?.documentPath);
void customisationSync.updatePluginList(true, local?.documentPath);
}
}
async function compareSelected() {
@@ -279,18 +267,12 @@
if (local && remote) {
if (!filename) {
if (await compareData(local, remote)) {
addOn.updatePluginList(true, local.documentPath);
void customisationSync.updatePluginList(true, local.documentPath);
}
return;
} else {
const localCopy =
local instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(local) : { ...local };
const remoteCopy =
remote instanceof PluginDataExDisplayV2 ? new PluginDataExDisplayV2(remote) : { ...remote };
localCopy.files = localCopy.files.filter((e) => e.filename == filename);
remoteCopy.files = remoteCopy.files.filter((e) => e.filename == filename);
if (await compareData(localCopy, remoteCopy, true)) {
addOn.updatePluginList(true, local.documentPath);
if (await customisationSync.compareFileUsingDisplayData(local, remote, filename)) {
void customisationSync.updatePluginList(true, local.documentPath);
}
}
return;
@@ -333,7 +315,7 @@
const selectedItem = list.find((e) => e.term == selected);
// const deletedPath = selectedItem.documentPath;
if (selectedItem && (await deleteData(selectedItem))) {
addOn.reloadPluginList(true);
void customisationSync.reloadPluginList(true);
}
}
async function duplicateItem() {
@@ -342,7 +324,7 @@
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
return;
}
const duplicateTermName = await core.confirm.askString(
const duplicateTermName = await customisationSync.askString(
translateMessage("Duplicate"),
translateMessage("device name"),
""
@@ -352,9 +334,7 @@
Logger(translateMessage('We can not use "/" to the device name'), LOG_LEVEL_NOTICE);
return;
}
const key = `${plugin.core.services.API.getSystemConfigDir()}/${local.files[0].filename}`;
await addOn.storeCustomizationFiles(key as FilePath, duplicateTermName);
await addOn.updatePluginList(false, addOn.filenameToUnifiedKey(key, duplicateTermName));
await customisationSync.duplicateData(local, duplicateTermName);
}
}
</script>
+16 -6
View File
@@ -1,17 +1,24 @@
import { mount, unmount } from "svelte";
import { App, Modal } from "@/deps.ts";
import ObsidianLiveSyncPlugin from "@/main.ts";
import { type App, Modal } from "@/deps.ts";
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
import type { CustomisationSyncDialogView } from "./customisationSyncView.ts";
import PluginPane from "./PluginPane.svelte";
export class PluginDialogModal extends Modal {
plugin: ObsidianLiveSyncPlugin;
customisationSync: CustomisationSyncDialogView;
hiddenFileSync: HiddenFileSyncInitialisationView;
component: ReturnType<typeof mount> | undefined;
isOpened() {
return this.component != undefined;
}
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
constructor(
app: App,
customisationSync: CustomisationSyncDialogView,
hiddenFileSync: HiddenFileSyncInitialisationView
) {
super(app);
this.plugin = plugin;
this.customisationSync = customisationSync;
this.hiddenFileSync = hiddenFileSync;
}
override onOpen() {
@@ -25,7 +32,10 @@ export class PluginDialogModal extends Modal {
if (!this.component) {
this.component = mount(PluginPane, {
target: contentEl,
props: { plugin: this.plugin, core: this.plugin.core },
props: {
customisationSync: this.customisationSync,
hiddenFileSync: this.hiddenFileSync,
},
});
}
}
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const svelteMocks = vi.hoisted(() => ({
mount: vi.fn(),
unmount: vi.fn(),
}));
vi.mock("svelte", () => svelteMocks);
vi.mock("@/deps.ts", () => ({
Modal: class Modal {
contentEl = { setCssStyles: vi.fn() };
titleEl = { setText: vi.fn() };
},
}));
vi.mock("./PluginPane.svelte", () => ({ default: "PluginPane" }));
import { PluginDialogModal } from "./PluginDialogModal.ts";
describe("PluginDialogModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("mounts the focused views once and releases the component on close", () => {
const component = { component: "customisation-sync" };
const customisationSync = { catalogue: {} };
const hiddenFileSync = { initialiseInternalFileSync: vi.fn() };
svelteMocks.mount.mockReturnValue(component);
const modal = new PluginDialogModal({} as never, customisationSync as never, hiddenFileSync);
modal.onOpen();
modal.onOpen();
expect(svelteMocks.mount).toHaveBeenCalledOnce();
expect(svelteMocks.mount).toHaveBeenCalledWith("PluginPane", {
target: modal.contentEl,
props: { customisationSync, hiddenFileSync },
});
expect(modal.isOpened()).toBe(true);
modal.onClose();
expect(svelteMocks.unmount).toHaveBeenCalledWith(component);
expect(modal.isOpened()).toBe(false);
});
});
+36 -80
View File
@@ -1,14 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import ObsidianLiveSyncPlugin from "@/main";
import {
ConfigSync,
type IPluginDataExDisplay,
pluginIsEnumerating,
pluginList,
pluginManifestStore,
pluginV2Progress,
} from "./CmdConfigSync.ts";
import type { CustomisationSyncDialogView, IPluginDataExDisplay } from "./customisationSyncView.ts";
import PluginCombo from "./PluginCombo.svelte";
import { Menu, type PluginManifest } from "@/deps.ts";
import { unique } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -19,38 +11,19 @@
type SYNC_MODE,
MODE_SHINY,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { normalizePath } from "@/deps";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import type { HiddenFileSyncInitialisationView } from "@/features/HiddenFileSync/hiddenFileSyncViews.ts";
import { $msg as translateMessage } from "@/common/translation";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
export let plugin: ObsidianLiveSyncPlugin;
export let core :LiveSyncBaseCore;
// $: core = plugin.core;
export let customisationSync: CustomisationSyncDialogView;
export let hiddenFileSync: HiddenFileSyncInitialisationView;
$: hideNotApplicable = false;
$: thisTerm = core.services.setting.getDeviceAndVaultName();
$: thisTerm = customisationSync.getDeviceAndVaultName();
const addOn = core.getAddOn<ConfigSync>(ConfigSync.name)!;
if (!addOn) {
const msg = translateMessage(
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue."
);
Logger(msg, LOG_LEVEL_NOTICE);
throw new Error(msg);
}
const addOnHiddenFileSync = core.getAddOn<HiddenFileSync>(HiddenFileSync.name) as HiddenFileSync;
if (!addOnHiddenFileSync) {
const msg = translateMessage(
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue."
);
Logger(msg, LOG_LEVEL_NOTICE);
throw new Error(msg);
}
const catalogue = customisationSync.catalogue;
const enumerationActive = customisationSync.enumerationActive;
const migrationProgress = customisationSync.migrationProgress;
const manifests = customisationSync.manifests;
let list: IPluginDataExDisplay[] = [];
@@ -61,21 +34,17 @@
let applyAllPluse = 0;
let isMaintenanceMode = false;
async function requestUpdate() {
await addOn.updatePluginList(true);
await customisationSync.updatePluginList(true);
}
async function requestReload() {
await addOn.reloadPluginList(true);
await customisationSync.reloadPluginList(true);
}
let allTerms = [] as string[];
pluginList.subscribe((e) => {
list = e;
allTerms = unique(list.map((e) => e.term));
});
pluginIsEnumerating.subscribe((e) => {
loading = e;
});
onMount(async () => {
requestUpdate();
let allTerms: string[] = [];
$: list = $catalogue;
$: allTerms = unique(list.map((entry) => entry.term));
$: loading = $enumerationActive;
onMount(() => {
void requestUpdate();
});
function filterList(list: IPluginDataExDisplay[], categories: string[]) {
@@ -104,15 +73,11 @@
SNIPPET: translateMessage("Snippets"),
};
async function scanAgain() {
await addOn.scanAllConfigFiles(true);
await customisationSync.scanAllConfigFiles(true);
await requestUpdate();
}
async function replicate() {
await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
await customisationSync.synchronise();
}
function selectAllNewest(selectMode: boolean) {
selectNewestPulse++;
@@ -126,17 +91,17 @@
applyAllPluse++;
}
async function applyData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.applyData(data);
return await customisationSync.applyData(data);
}
async function compareData(
docA: IPluginDataExDisplay,
docB: IPluginDataExDisplay,
compareEach = false
): Promise<boolean> {
return await addOn.compareUsingDisplayData(docA, docB, compareEach);
return await customisationSync.compareUsingDisplayData(docA, docB, compareEach);
}
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await addOn.deleteData(data);
return await customisationSync.deleteData(data);
}
function askMode(evt: MouseEvent, title: string, key: string) {
const menu = new Menu();
@@ -161,9 +126,8 @@
}
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
setMode(key, MODE_AUTOMATIC);
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
const files = customisationSync.getConfiguredTargetFiles(key);
void hiddenFileSync.initialiseInternalFileSync(direction, true, files);
}
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
const menu = new Menu();
@@ -196,7 +160,7 @@
applyData,
compareData,
deleteData,
plugin,
customisationSync,
isMaintenanceMode,
};
@@ -236,22 +200,12 @@
);
if (mode == MODE_SELECTIVE) {
automaticList.delete(key);
delete plugin.core.settings.pluginSyncExtendedSetting[key];
automaticListDisp = automaticList;
} else {
automaticList.set(key, mode);
automaticListDisp = automaticList;
if (!(key in plugin.core.settings.pluginSyncExtendedSetting)) {
plugin.core.settings.pluginSyncExtendedSetting[key] = {
key,
mode,
files: [],
};
}
plugin.core.settings.pluginSyncExtendedSetting[key].files = files;
plugin.core.settings.pluginSyncExtendedSetting[key].mode = mode;
}
core.services.setting.saveSettingData();
customisationSync.updateConfiguredMode(key, mode, files);
}
function getIcon(mode: SYNC_MODE) {
if (mode in ICONS) {
@@ -264,7 +218,7 @@
let automaticListDisp = new Map<string, SYNC_MODE>();
// apply current configuration to the dialogue
for (const { key, mode } of Object.values(plugin.core.settings.pluginSyncExtendedSetting)) {
for (const { key, mode } of customisationSync.getConfiguredModes()) {
automaticList.set(key, mode);
}
@@ -273,7 +227,7 @@
let displayKeys: Record<string, string[]> = {};
function computeDisplayKeys(list: IPluginDataExDisplay[]) {
const extraKeys = Object.keys(plugin.core.settings.pluginSyncExtendedSetting);
const extraKeys = customisationSync.getConfiguredModes().map(({ key }) => key);
return [
...list,
...extraKeys
@@ -303,7 +257,7 @@
for (const item of deleteItems) {
await deleteData(item);
}
addOn.reloadPluginList(true);
void customisationSync.reloadPluginList(true);
}
let nameMap = new Map<string, string>();
@@ -324,7 +278,7 @@
}
nameMap = newMap;
}
$: updateNameMap($pluginManifestStore);
$: updateNameMap($manifests);
let displayEntries = [] as [string, string][];
$: {
@@ -335,7 +289,7 @@
$: {
pluginEntries = groupBy(filterList(list, ["PLUGIN_MAIN", "PLUGIN_DATA", "PLUGIN_ETC"]), "name");
}
let useSyncPluginEtc = plugin.core.settings.usePluginEtc;
let useSyncPluginEtc = customisationSync.isPluginEtcEnabled();
</script>
<div class="buttonsWrap">
@@ -357,8 +311,10 @@
</div>
</div>
<div class="loading">
{#if loading || $pluginV2Progress !== 0}
<span>{translateMessage("Updating list...")}{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
{#if loading || $migrationProgress !== 0}
<span
>{translateMessage("Updating list...")}{$migrationProgress == 0 ? "" : ` (${$migrationProgress})`}</span
>
{/if}
</div>
<div class="list">
@@ -0,0 +1,346 @@
import { diff_match_patch, parseYaml } from "@/deps.ts";
import type {
diff_result,
FilePath,
FilePathWithPrefix,
LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, getDocData, getDocDataAsArray, isDocContentSame } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { decodeBinary } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { base64ToArrayBuffer, base64ToString } from "octagonal-wheels/binary/base64";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import type { CatalogueOperations } from "./catalogueOperations.ts";
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
import type { SnapshotOperations } from "./snapshotOperations.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
const { deserialize } = createCustomisationSyncCodec({ digestHash, parseYaml });
type ApplicationDatabase = Pick<LiveSyncLocalDB, "getDBEntry">;
type ApplicationStorage = Pick<
StorageAccess,
"ensureDir" | "readHiddenFileBinary" | "readHiddenFileText" | "writeHiddenFileAuto"
>;
type ApplicationPath = Pick<CustomisationSyncPathOperations, "filenameToUnifiedKey">;
type ApplicationSnapshotOperations = Pick<
SnapshotOperations,
"isV2Enabled" | "storeCustomisationFileV2" | "storeCustomizationFiles" | "deleteConfigOnDatabase"
>;
type ApplicationCatalogue = Pick<
CatalogueOperations,
"findPlugins" | "manifestLookup" | "updatePluginList" | "updatePluginListV2"
>;
export type ApplicationOperationsDependencies = {
getLocalDatabase(): ApplicationDatabase;
storageAccess: ApplicationStorage;
path: ApplicationPath;
log: LogFunction;
getConfigDir(): string;
getDeviceAndVaultName(): string;
resolveJsonConflict(
path: FilePath,
files: [LoadedEntryPluginDataExFile, LoadedEntryPluginDataExFile],
remoteName: string,
apply: (content: string) => Promise<boolean>
): Promise<boolean>;
selectTextFile(path: FilePath, diffResult: diff_result, remoteName: string): Promise<"A" | "B" | false>;
reloadPlugin(configDir: string, pluginName: string): Promise<void>;
askRestart(): void;
snapshotOperations: ApplicationSnapshotOperations;
catalogueOperations: ApplicationCatalogue;
};
/**
* Owns the Customisation Sync dialogue's compare, apply, duplicate, and
* delete workflows. It deliberately consumes the shared snapshot capability
* and catalogue owner, leaving lifecycle, event admission, and scanning in
* the context.
*/
export class ApplicationOperations {
constructor(private readonly dependencies: ApplicationOperationsDependencies) {}
private get configDir() {
return this.dependencies.getConfigDir();
}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private get storageAccess() {
return this.dependencies.storageAccess;
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
async compareFileUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
filename: string
): Promise<boolean> {
const dataACopy =
dataA instanceof PluginDataExDisplayV2
? new PluginDataExDisplayV2(dataA, this.dependencies.catalogueOperations.manifestLookup)
: { ...dataA };
const dataBCopy =
dataB instanceof PluginDataExDisplayV2
? new PluginDataExDisplayV2(dataB, this.dependencies.catalogueOperations.manifestLookup)
: { ...dataB };
dataACopy.files = dataACopy.files.filter((file) => file.filename == filename);
dataBCopy.files = dataBCopy.files.filter((file) => file.filename == filename);
return await this.compareUsingDisplayData(dataACopy, dataBCopy, true);
}
async compareUsingDisplayData(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach = false) {
const loadFile = async (data: IPluginDataExDisplay) => {
if (data instanceof PluginDataExDisplayV2 || compareEach) {
return data.files[0] as LoadedEntryPluginDataExFile;
}
const loadDoc = await this.localDatabase.getDBEntry(data.documentPath);
if (!loadDoc) return false;
const pluginData = deserialize(getDocDataAsArray(loadDoc.data), {}) as PluginDataEx;
pluginData.documentPath = data.documentPath;
const file = pluginData.files[0];
const doc = { ...loadDoc, ...file, datatype: "newnote" } as LoadedEntryPluginDataExFile;
return doc;
};
const fileA = await loadFile(dataA);
const fileB = await loadFile(dataB);
this._log(`Comparing: ${dataA.documentPath} <-> ${dataB.documentPath}`, LOG_LEVEL_VERBOSE);
if (!fileA || !fileB) {
this._log(
`Could not load ${dataA.name} for comparison: ${!fileA ? dataA.term : ""}${!fileB ? dataB.term : ""}`,
LOG_LEVEL_NOTICE
);
return false;
}
const path = fileA.filename.split("/").pop() as FilePath;
if (path.endsWith(".json")) {
return serialized("config:merge-data", async () => {
this._log("Opening data-merging dialog", LOG_LEVEL_VERBOSE);
return await this.dependencies.resolveJsonConflict(path, [fileA, fileB], dataB.term, async (result) => {
try {
return await this.applyData(dataA, result);
} catch (ex) {
this._log("Could not apply merged file");
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
});
});
} else {
const dmp = new diff_match_patch();
let docAData = getDocData(fileA.data);
let docBData = getDocData(fileB.data);
if (fileA?.datatype != "plain") {
docAData = base64ToString(docAData);
}
if (fileB?.datatype != "plain") {
docBData = base64ToString(docBData);
}
const diffMap = dmp.diff_linesToChars_(docAData, docBData);
const diff = dmp.diff_main(diffMap.chars1, diffMap.chars2, false);
dmp.diff_charsToLines_(diff, diffMap.lineArray);
dmp.diff_cleanupSemantic(diff);
const diffResult: diff_result = {
left: { rev: "A", ...fileA, data: docAData },
right: { rev: "B", ...fileB, data: docBData },
diff: diff,
};
const ret = await this.dependencies.selectTextFile(path, diffResult, dataB.term);
if (ret === false) return false;
const resultContent = ret == "A" ? docAData : ret == "B" ? docBData : undefined;
if (resultContent) {
return await this.applyData(dataA, resultContent);
}
return false;
}
}
async duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void> {
const path = `${this.configDir}/${data.files[0].filename}` as FilePath;
await this.dependencies.snapshotOperations.storeCustomizationFiles(path, deviceName);
await this.dependencies.catalogueOperations.updatePluginList(
false,
this.dependencies.path.filenameToUnifiedKey(path, deviceName)
);
}
async applyDataV2(data: PluginDataExDisplayV2, content?: string): Promise<boolean> {
const baseDir = this.configDir;
try {
if (content) {
// Preserve the inherited truthiness check: an explicitly empty
// replacement is treated as the no-content path.
const filename = data.files[0].filename;
this._log(`Applying ${filename} of ${data.displayName || data.name}..`);
const path = `${baseDir}/${filename}` as FilePath;
await this.storageAccess.ensureDir(path);
// If the content has applied, modified time will be updated to the current time.
await this.storageAccess.writeHiddenFileAuto(path, content);
await this.dependencies.snapshotOperations.storeCustomisationFileV2(
path,
this.dependencies.getDeviceAndVaultName()
);
} else {
const files = data.files;
for (const f of files) {
// If files have applied, modified time will be updated to the current time.
const stat = { mtime: f.mtime, ctime: f.ctime };
const path = `${baseDir}/${f.filename}` as FilePath;
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
// const contentEach = createBlob(f.data);
await this.storageAccess.ensureDir(path);
if (f.datatype == "newnote") {
let oldData;
try {
oldData = await this.storageAccess.readHiddenFileBinary(path);
} catch (ex) {
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
oldData = new ArrayBuffer(0);
}
const content = base64ToArrayBuffer(f.data);
if (await isDocContentSame(oldData, content)) {
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
continue;
}
await this.storageAccess.writeHiddenFileAuto(path, content, stat);
} else {
let oldData;
try {
oldData = await this.storageAccess.readHiddenFileText(path);
} catch (ex) {
this._log(`Could not read the file ${f.filename}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
oldData = "";
}
const content = getDocData(f.data);
if (await isDocContentSame(oldData, content)) {
this._log(`The file ${f.filename} is already up-to-date`, LOG_LEVEL_VERBOSE);
continue;
}
await this.storageAccess.writeHiddenFileAuto(path, content, stat);
}
this._log(`Applied ${f.filename} of ${data.displayName || data.name}..`);
await this.dependencies.snapshotOperations.storeCustomisationFileV2(
path,
this.dependencies.getDeviceAndVaultName()
);
}
}
} catch (ex) {
this._log(`Applying ${data.displayName || data.name}.. Failed`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
return true;
}
async applyData(data: IPluginDataExDisplay, content?: string): Promise<boolean> {
this._log(`Applying ${data.displayName || data.name}..`);
if (data instanceof PluginDataExDisplayV2) {
return this.applyDataV2(data, content);
}
return this.applyDataV1(data, content);
}
private async applyDataV1(data: IPluginDataExDisplay, content?: string): Promise<boolean> {
const baseDir = this.configDir;
try {
if (!data.documentPath) throw new LiveSyncError("InternalError: Document path not exist");
const dx = await this.localDatabase.getDBEntry(data.documentPath);
if (dx == false) {
throw new LiveSyncError("Not found on database");
}
const loadedData = deserialize(getDocDataAsArray(dx.data), {}) as PluginDataEx;
for (const f of loadedData.files) {
this._log(`Applying ${f.filename} of ${data.displayName || data.name}..`);
try {
// console.dir(f);
const path = `${baseDir}/${f.filename}`;
await this.storageAccess.ensureDir(path);
if (!content) {
const dt = decodeBinary(f.data);
await this.storageAccess.writeHiddenFileAuto(path, dt);
} else {
await this.storageAccess.writeHiddenFileAuto(path, content);
}
this._log(`Applying ${f.filename} of ${data.displayName || data.name}.. Done`);
} catch (ex) {
this._log(`Applying ${f.filename} of ${data.displayName || data.name}.. Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
const uPath = `${baseDir}/${loadedData.files[0].filename}` as FilePath;
await this.dependencies.snapshotOperations.storeCustomizationFiles(uPath);
// The inherited workflow refreshes once through persistence, then
// explicitly refreshes again with the dialogue's notice flag.
await this.dependencies.catalogueOperations.updatePluginList(true, uPath);
await delay(100);
this._log(`Config ${data.displayName || data.name} has been applied`, LOG_LEVEL_NOTICE);
if (data.category == "PLUGIN_DATA" || data.category == "PLUGIN_MAIN") {
await this.dependencies.reloadPlugin(baseDir, data.name);
} else if (data.category == "CONFIG") {
this.dependencies.askRestart();
}
return true;
} catch (ex) {
this._log(`Applying ${data.displayName || data.name}.. Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
}
async deleteData(data: PluginDataEx): Promise<boolean> {
try {
if (data.documentPath) {
const delList: FilePathWithPrefix[] = [];
if (this.dependencies.snapshotOperations.isV2Enabled()) {
const deleteList = this.dependencies.catalogueOperations
.findPlugins(data.documentPath)
.filter((entry) => entry instanceof PluginDataExDisplayV2)
.map((entry) => entry.files)
.flat();
for (const e of deleteList) {
delList.push(e.path);
}
}
delList.push(data.documentPath);
const p = delList.map(async (e) => {
await this.dependencies.snapshotOperations.deleteConfigOnDatabase(e);
// Preserve the inherited unconditional refresh after the
// persistence wrapper, including when it emitted no refresh.
await this.dependencies.catalogueOperations.updatePluginList(false, e);
});
await Promise.allSettled(p);
// Preserve the inherited success result even when individual
// deletion/refresh promises settle unsuccessfully.
this._log(
`Deleted: ${data.category}/${data.name} of ${data.category} (${delList.length} items)`,
LOG_LEVEL_NOTICE
);
}
return true;
} catch (ex) {
this._log(`Failed to delete: ${data.documentPath}`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
}
}
@@ -0,0 +1,306 @@
import { describe, expect, it, vi } from "vitest";
const asyncHarness = vi.hoisted(() => ({
delay: vi.fn(async () => undefined),
fireAndForget: vi.fn((operation: () => unknown) => {
void operation();
}),
}));
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
parseYaml: vi.fn(),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
delay: asyncHarness.delay,
fireAndForget: asyncHarness.fireAndForget,
};
});
import type { FilePath, FilePathWithPrefix, LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { PluginManifest } from "@/deps.ts";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import { ApplicationOperations, type ApplicationOperationsDependencies } from "./applicationOperations.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { SnapshotPersistenceResult } from "./snapshotPersistence.ts";
import { SnapshotOperations } from "./snapshotOperations.ts";
import type { IPluginDataExDisplay } from "./customisationSyncView.ts";
const codec = createCustomisationSyncCodec({ digestHash, parseYaml: () => undefined });
function createOperations() {
const events: string[] = [];
let usePluginSyncV2 = false;
type PersistenceResult = SnapshotPersistenceResult<true>;
const getDBEntry = vi.fn(async (_path: FilePathWithPrefix) => false as false | LoadedEntry);
const ensureDir = vi.fn(async (_path: string) => {
events.push("ensure-dir");
return true;
});
const readHiddenFileBinary = vi.fn(async (_path: string) => new ArrayBuffer(0));
const readHiddenFileText = vi.fn(async (_path: string) => "");
const writeHiddenFileAuto = vi.fn(async (_path: string, _data: string | ArrayBuffer) => {
events.push("write-file");
return true;
});
const storeCustomisationFileV2 = vi.fn(
async (): Promise<PersistenceResult> => ({
value: true,
status: "saved" as const,
refreshes: [] as const,
})
);
const storeCustomizationFiles = vi.fn(
async (): Promise<PersistenceResult> => ({
value: true,
status: "saved" as const,
refreshes: [] as const,
})
);
const deleteConfigOnDatabase = vi.fn(
async (): Promise<PersistenceResult> => ({
value: true,
status: "deleted" as const,
refreshes: [] as const,
})
);
const updatePluginList = vi.fn(async (showMessage: boolean, _path?: FilePathWithPrefix | FilePath) => {
events.push(`refresh-v1:${showMessage}`);
});
const updatePluginListV2 = vi.fn(async (_showMessage: boolean, _path: FilePathWithPrefix) => {
events.push("refresh-v2");
});
const findPlugins = vi.fn(() => [] as readonly IPluginDataExDisplay[]);
const reloadPlugin = vi.fn(async (_configDir: string, _pluginName: string) => {
events.push("reload-plugin");
});
const askRestart = vi.fn(() => {
events.push("ask-restart");
});
const catalogueOperations = {
findPlugins,
manifestLookup: new Map<string, PluginManifest>(),
updatePluginList,
updatePluginListV2,
};
const snapshotOperations = new SnapshotOperations({
getSettings: () => ({ usePluginSyncV2 }),
getDeviceAndVaultName: () => "device-a",
log: vi.fn(),
snapshotPersistence: {
storeCustomisationFileV2,
storeCustomizationFiles,
deleteConfigOnDatabase,
},
catalogueOperations,
});
const dependencies: ApplicationOperationsDependencies = {
getLocalDatabase: () => ({ getDBEntry }),
storageAccess: {
ensureDir,
readHiddenFileBinary,
readHiddenFileText,
writeHiddenFileAuto,
},
path: {
filenameToUnifiedKey: (path, term) => `ix:${term}/CONFIG/${path.split("/").pop()}.md` as FilePathWithPrefix,
},
log: vi.fn(),
getConfigDir: () => ".obsidian",
getDeviceAndVaultName: () => "device-a",
resolveJsonConflict: vi.fn(async () => false),
selectTextFile: vi.fn(async (): Promise<"A" | "B" | false> => false),
reloadPlugin,
askRestart,
snapshotOperations,
catalogueOperations,
};
return {
application: new ApplicationOperations(dependencies),
dependencies,
events,
setUseV2: (value: boolean) => {
usePluginSyncV2 = value;
},
getDBEntry,
persistence: { deleteConfigOnDatabase, storeCustomisationFileV2, storeCustomizationFiles },
catalogue: { findPlugins, updatePluginList, updatePluginListV2 },
storage: { ensureDir, readHiddenFileBinary, readHiddenFileText, writeHiddenFileAuto },
reloadPlugin,
askRestart,
};
}
const display = {
documentPath: "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix,
category: "PLUGIN_DATA",
name: "example",
term: "device-a",
files: [
{ filename: "plugins/example/data.json", data: ["a"], mtime: 1, size: 1 },
{ filename: "plugins/example/other.json", data: ["b"], mtime: 2, size: 1 },
],
mtime: 2,
} satisfies IPluginDataExDisplay;
describe("Customisation Sync application operations", () => {
it("keeps file-level comparison clones and duplication behaviour inside the owner", async () => {
const fixture = createOperations();
const compareUsingDisplayData = vi
.spyOn(fixture.application, "compareUsingDisplayData")
.mockResolvedValue(true);
await expect(
fixture.application.compareFileUsingDisplayData(display, display, "plugins/example/data.json")
).resolves.toBe(true);
const [left, right, compareEach] = compareUsingDisplayData.mock.calls[0];
expect(left.files.map((file) => file.filename)).toEqual(["plugins/example/data.json"]);
expect(right.files.map((file) => file.filename)).toEqual(["plugins/example/data.json"]);
expect(compareEach).toBe(true);
expect(display.files).toHaveLength(2);
await fixture.application.duplicateData(display, "device-b");
expect(fixture.persistence.storeCustomizationFiles).toHaveBeenCalledWith(
".obsidian/plugins/example/data.json",
"device-b"
);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false, "ix:device-b/CONFIG/data.json.md");
});
it("uses the compared filename for legacy file comparisons", async () => {
const fixture = createOperations();
await expect(
fixture.application.compareFileUsingDisplayData(display, display, "plugins/example/data.json")
).resolves.toBe(false);
expect(fixture.dependencies.resolveJsonConflict).toHaveBeenCalledWith(
"data.json",
expect.any(Array),
"device-a",
expect.any(Function)
);
});
it("awaits V1 refreshes before the explicit effect and preserves reload ordering", async () => {
const fixture = createOperations();
fixture.setUseV2(false);
fixture.persistence.storeCustomizationFiles.mockImplementation(async () => {
fixture.events.push("persist");
return {
value: true,
status: "saved" as const,
refreshes: [
{
mode: "v1" as const,
timing: "await" as const,
path: "ix:device-a/PLUGIN_MAIN/example.md" as FilePathWithPrefix,
},
],
};
});
fixture.getDBEntry.mockResolvedValue({
data: codec.serialize({
category: "PLUGIN_MAIN",
name: "example",
term: "device-a",
files: [{ filename: "plugins/example/main.js", data: ["source"], mtime: 1, size: 6 }],
mtime: 1,
} satisfies PluginDataEx),
} as LoadedEntry);
const data = { ...display, category: "PLUGIN_MAIN", name: "example" } satisfies IPluginDataExDisplay;
await expect(fixture.application.applyData(data, "replacement")).resolves.toBe(true);
expect(fixture.events).toEqual([
"ensure-dir",
"write-file",
"persist",
"refresh-v1:false",
"refresh-v1:true",
"reload-plugin",
]);
expect(fixture.reloadPlugin).toHaveBeenCalledWith(".obsidian", "example");
expect(asyncHarness.delay).toHaveBeenCalledWith(100);
});
it("starts V2 catalogue refreshes without awaiting them", async () => {
const fixture = createOperations();
let releaseRefresh!: () => void;
const refresh = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
fixture.setUseV2(true);
fixture.persistence.storeCustomisationFileV2.mockResolvedValue({
value: true,
status: "saved",
refreshes: [
{
mode: "v2",
timing: "fire-and-forget",
path: "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix,
},
],
});
fixture.catalogue.updatePluginListV2.mockImplementation(async () => await refresh);
const data = new PluginDataExDisplayV2(
{
...display,
files: [{ filename: "app.json", data: ["source"], mtime: 1, size: 6 }],
},
new Map()
);
await expect(fixture.application.applyData(data, "replacement")).resolves.toBe(true);
expect(fixture.catalogue.updatePluginListV2).toHaveBeenCalledWith(
false,
"ix:device-a/CONFIG/app.json%app.json"
);
releaseRefresh();
await refresh;
});
it("deletes the V2 files and binder through the direct owners", async () => {
const fixture = createOperations();
fixture.setUseV2(true);
const v2Path = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix;
const binderPath = "ix:device-a/PLUGIN_DATA/example.md" as FilePathWithPrefix;
const v2Entry = new PluginDataExDisplayV2(
{
...display,
documentPath: binderPath,
files: [
{
filename: "data.json",
path: v2Path,
data: ["source"],
mtime: 1,
ctime: 1,
size: 6,
datatype: "plain",
} as never,
],
},
new Map()
);
fixture.catalogue.findPlugins.mockReturnValue([v2Entry]);
await expect(
fixture.application.deleteData({
...display,
documentPath: binderPath,
})
).resolves.toBe(true);
expect(fixture.persistence.deleteConfigOnDatabase).toHaveBeenNthCalledWith(1, v2Path, false);
expect(fixture.persistence.deleteConfigOnDatabase).toHaveBeenNthCalledWith(2, binderPath, false);
expect(fixture.catalogue.updatePluginList).toHaveBeenNthCalledWith(1, false, v2Path);
expect(fixture.catalogue.updatePluginList).toHaveBeenNthCalledWith(2, false, binderPath);
});
});
@@ -0,0 +1,126 @@
import { createBlob, getDocDataAsArray } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type {
AnyEntry,
FilePathWithPrefix,
LOG_LEVEL,
SavingEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { ICXHeader } from "@/common/types.ts";
import type { SnapshotPersistence } from "./snapshotPersistence.ts";
import type { CustomisationSyncReadCodec } from "./customisationSyncReadOperations.ts";
type CatalogueMigrationDatabase = Pick<LiveSyncLocalDB, "getDBEntry" | "putDBEntry">;
type CatalogueMigrationCodec = Pick<CustomisationSyncReadCodec, "deserialize"> & {
dummyHead: string;
dummyEnd: string;
};
export type CatalogueMigrationDependencies = {
getLocalDatabase(): CatalogueMigrationDatabase;
path: Pick<IPathService, "path2id">;
log: LogFunction;
snapshotPersistence: Pick<SnapshotPersistence, "deleteConfigOnDatabase">;
refreshV1(showMessage: boolean, path: FilePathWithPrefix): Promise<void>;
codec: CatalogueMigrationCodec;
};
/** Bridges persisted V1 binders into the V2 per-file document format. */
export class CatalogueMigration {
constructor(private readonly dependencies: CatalogueMigrationDependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
async migrateV1ToV2(showMessage: boolean, entry: AnyEntry): Promise<void> {
const v1Path = entry.path;
this._log(`Migrating ${entry.path} to V2`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
if (entry.deleted) {
this._log(`The entry ${v1Path} is already deleted`, LOG_LEVEL_VERBOSE);
return;
}
// Compatibility question: the inherited conjunction admits any `ix:`
// path or any `.md` path, although the log describes a stricter binder
// check. Preserve it until malformed migration candidates are covered.
if (!v1Path.endsWith(".md") && !v1Path.startsWith(ICXHeader)) {
this._log(`The entry ${v1Path} is not a customisation sync binder`, LOG_LEVEL_VERBOSE);
return;
}
if (v1Path.indexOf("%") !== -1) {
this._log(`The entry ${v1Path} is already migrated`, LOG_LEVEL_VERBOSE);
return;
}
const loadedEntry = await this.dependencies.getLocalDatabase().getDBEntry(v1Path);
if (!loadedEntry) {
this._log(`The entry ${v1Path} is not found`, LOG_LEVEL_VERBOSE);
return;
}
const pluginData = this.dependencies.codec.deserialize(getDocDataAsArray(loadedEntry.data), {}) as {
category: string;
files: Array<{ filename: string; data: string[] }>;
};
const prefixPath = v1Path.slice(0, -".md".length) + "%";
const category = pluginData.category;
for (const f of pluginData.files) {
const stripTable: Record<string, number> = {
CONFIG: 0,
THEME: 2,
SNIPPET: 1,
PLUGIN_MAIN: 2,
PLUGIN_DATA: 2,
PLUGIN_ETC: 2,
};
const deletePrefixCount = stripTable?.[category] ?? 1;
const relativeFilename = f.filename.split("/").slice(deletePrefixCount).join("/");
const v2Path = (prefixPath + relativeFilename) as FilePathWithPrefix;
this._log(`Migrating ${v1Path} / ${relativeFilename} to ${v2Path}`, LOG_LEVEL_VERBOSE);
const newId = await this.dependencies.path.path2id(v2Path);
const data = createBlob([
this.dependencies.codec.dummyHead,
this.dependencies.codec.dummyEnd,
...getDocDataAsArray(f.data),
]);
const saving: SavingEntry = {
...loadedEntry,
_rev: undefined,
_id: newId,
path: v2Path,
data,
datatype: "plain",
type: "plain",
children: [],
eden: {},
};
const result = await this.dependencies.getLocalDatabase().putDBEntry(saving);
if (result && result.ok) {
this._log(`Migrated ${v1Path} / ${f.filename} to ${v2Path}`, LOG_LEVEL_INFO);
const deletion = await this.dependencies.snapshotPersistence.deleteConfigOnDatabase(v1Path);
const deleted = deletion.value;
if (deleted) {
this._log(`Deleted ${v1Path} successfully`, LOG_LEVEL_INFO);
} else {
this._log(`Failed to delete ${v1Path}`, LOG_LEVEL_NOTICE);
}
// Compatibility: the inherited migration called the context
// deletion wrapper, which awaited its V1 catalogue refresh.
// Apply that refresh explicitly now that deletion is a host-
// neutral persistence operation, and only when deletion emitted
// the same mutation outcome.
for (const refresh of deletion.refreshes) {
if (refresh.mode == "v1" && refresh.timing == "await") {
await this.dependencies.refreshV1(false, refresh.path);
}
}
}
}
}
}
@@ -0,0 +1,216 @@
import { parseYaml } from "@/deps.ts";
import { writable } from "svelte/store";
import type {
AnyEntry,
FilePathWithPrefix,
LoadedEntry,
LOG_LEVEL,
ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ICXHeader } from "@/common/types.ts";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { CatalogueMigration } from "./catalogueMigration.ts";
import { CatalogueState } from "./catalogueState.ts";
import { CatalogueV1 } from "./catalogueV1.ts";
import { CatalogueV2 } from "./catalogueV2.ts";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import type { SnapshotPersistence } from "./snapshotPersistence.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
const {
serialize,
deserialize,
dummyHead: DUMMY_HEAD,
dummyEnd: DUMMY_END,
} = createCustomisationSyncCodec({ digestHash, parseYaml });
const READ_CODEC = { deserialize, serialize };
const MIGRATION_CODEC = { deserialize, dummyHead: DUMMY_HEAD, dummyEnd: DUMMY_END };
const V2_CODEC = { dummyEnd: DUMMY_END };
type CatalogueSettings = Pick<ObsidianLiveSyncSettings, "usePluginSync" | "usePluginSyncV2">;
type CatalogueDatabase = Pick<LiveSyncLocalDB, "findEntries" | "getDBEntry" | "putDBEntry">;
export type CatalogueOperationsDependencies = {
getSettings(): CatalogueSettings;
getLocalDatabase(): CatalogueDatabase;
path: Pick<IPathService, "getPath" | "path2id">;
log: LogFunction;
snapshotPersistence: Pick<SnapshotPersistence, "deleteConfigOnDatabase">;
publishScanCount(count: number): void;
};
/** Coordinates the shared catalogue state, scan queue, and format modules. */
export class CatalogueOperations {
private readonly dependencies: CatalogueOperationsDependencies;
private readonly catalogueState = new CatalogueState();
private readonly scanProgress = reactiveSource(0);
private readonly pluginScanningChanged: Parameters<ReactiveSource<number>["onChanged"]>[0] = (event) => {
this.enumerationActive.set(event.value != 0);
this.dependencies.publishScanCount(event.value);
};
private readonly pluginScanProcessor: QueueProcessor<AnyEntry, AnyEntry>;
private readonly catalogueV1: CatalogueV1;
private readonly catalogueV2: CatalogueV2;
private readonly catalogueMigration: CatalogueMigration;
readonly enumerationActive = writable(false);
readonly catalogue = this.catalogueState.catalogue;
readonly migrationProgress = this.catalogueState.migrationProgress;
readonly manifests = this.catalogueState.manifests;
constructor(dependencies: CatalogueOperationsDependencies) {
this.dependencies = dependencies;
this.catalogueV1 = new CatalogueV1({
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
path: {
getPath: (entry) => this.getPath(entry),
},
log: (message, level, key) => this._log(message, level, key),
state: this.catalogueState,
});
this.catalogueV2 = new CatalogueV2({
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
log: (message, level, key) => this._log(message, level, key),
state: this.catalogueState,
codec: V2_CODEC,
});
this.catalogueMigration = new CatalogueMigration({
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
path: {
path2id: (path) => this.path2id(path),
},
log: (message, level, key) => this._log(message, level, key),
snapshotPersistence: this.dependencies.snapshotPersistence,
refreshV1: async (showMessage, path) => await this.updatePluginList(showMessage, path),
codec: MIGRATION_CODEC,
});
this.scanProgress.onChanged(this.pluginScanningChanged);
// The single queue deliberately chooses V1 loading or migration when
// each item starts. Settings can change after enqueueing an item.
this.pluginScanProcessor = new QueueProcessor(
async (v: AnyEntry[]) => {
const plugin = v[0];
if (this.dependencies.getSettings().usePluginSyncV2) {
await this.migrateV1ToV2(false, plugin);
return [];
}
await this.catalogueV1.load(plugin, READ_CODEC);
return [];
},
{
suspended: false,
batchSize: 1,
concurrentLimit: 10,
delay: 100,
yieldThreshold: 10,
maintainDelay: false,
totalRemainingReactiveSource: this.scanProgress,
}
).startPipeline();
}
private get settings() {
return this.dependencies.getSettings();
}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.path.getPath(entry);
}
private async path2id(filename: FilePathWithPrefix) {
return await this.dependencies.path.path2id(filename);
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
/** The current manifest lookup passed to V2 display rows. */
get manifestLookup() {
return this.catalogueState.manifestLookup;
}
/** Returns every row matching a document path, preserving legacy duplicates. */
findPlugins(documentPath: FilePathWithPrefix | string): readonly IPluginDataExDisplay[] {
return this.catalogueState.findPlugins(documentPath);
}
dispose(): void {
this.pluginScanProcessor.terminate();
this.scanProgress.offChanged(this.pluginScanningChanged);
this.enumerationActive.set(false);
this.dependencies.publishScanCount(0);
}
async reloadPluginList(showMessage: boolean): Promise<void> {
this.catalogueState.clearForReload();
await this.updatePluginList(showMessage);
}
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
if (!this.settings.usePluginSync) {
this.pluginScanProcessor.clearQueue();
this.catalogueState.clearForDisabledRefresh();
return;
}
try {
this.catalogueState.beginUpdate();
const updatedDocumentId = updatedDocumentPath ? await this.path2id(updatedDocumentPath) : "";
const plugins = updatedDocumentPath
? this.localDatabase.findEntries(updatedDocumentId, updatedDocumentId + "\u{10ffff}", {
include_docs: true,
key: updatedDocumentId,
limit: 1,
})
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
for await (const v of plugins) {
if (v.deleted || v._deleted) continue;
if (v.path.indexOf("%") !== -1) {
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
continue;
}
const path = v.path || this.getPath(v);
if (updatedDocumentPath && updatedDocumentPath != path) continue;
this.pluginScanProcessor.enqueue(v);
}
} finally {
this.enumerationActive.set(false);
this.catalogueState.endUpdate();
}
this.enumerationActive.set(false);
}
async createPluginDataExFileV2(
unifiedPathV2: FilePathWithPrefix,
loaded?: LoadedEntry
): Promise<false | LoadedEntryPluginDataExFile> {
return await this.catalogueV2.createPluginDataExFileV2(unifiedPathV2, loaded);
}
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix) {
return this.catalogueV2.createPluginDataFromV2(unifiedPathV2);
}
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
await this.catalogueV2.updatePluginListV2(showMessage, unifiedFilenameWithKey);
}
private async migrateV1ToV2(showMessage: boolean, entry: AnyEntry): Promise<void> {
await this.catalogueMigration.migrateV1ToV2(showMessage, entry);
}
}
@@ -0,0 +1,222 @@
import { get } from "svelte/store";
import { beforeEach, describe, expect, it, vi } from "vitest";
const testState = vi.hoisted(() => ({
processors: [] as Array<{
clearQueue: ReturnType<typeof vi.fn>;
enqueue: ReturnType<typeof vi.fn>;
terminate: ReturnType<typeof vi.fn>;
startPipeline: ReturnType<typeof vi.fn>;
process: (entries: AnyEntry[]) => Promise<AnyEntry[]>;
}>,
reactiveSources: [] as Array<{
value: number;
onChanged: ReturnType<typeof vi.fn>;
offChanged: ReturnType<typeof vi.fn>;
}>,
}));
vi.mock("@/deps.ts", () => ({
parseYaml: vi.fn(),
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
}));
vi.mock("@/common/utils.ts", () => ({
fireAndForget: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("octagonal-wheels/concurrency/processor", () => ({
QueueProcessor: class QueueProcessor {
clearQueue = vi.fn();
enqueue = vi.fn();
terminate = vi.fn();
startPipeline = vi.fn(() => this);
process: (entries: AnyEntry[]) => Promise<AnyEntry[]>;
constructor(process: (entries: AnyEntry[]) => Promise<AnyEntry[]>) {
this.process = process;
testState.processors.push(this);
}
},
}));
vi.mock("octagonal-wheels/dataobject/reactive", () => ({
reactiveSource: vi.fn((value: number) => {
const source = {
value,
onChanged: vi.fn(),
offChanged: vi.fn(),
};
testState.reactiveSources.push(source);
return source;
}),
}));
import { scheduleTask } from "@/common/utils.ts";
import type {
AnyEntry,
DocumentID,
FilePathWithPrefix,
LoadedEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import { CatalogueOperations, type CatalogueOperationsDependencies } from "./catalogueOperations.ts";
import type { SnapshotPersistenceResult } from "./snapshotPersistence.ts";
const codec = createCustomisationSyncCodec({
digestHash,
parseYaml: () => undefined,
});
const v2Path = "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix;
const v1Path = "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix;
function loadedEntry(): LoadedEntry {
const data = `${codec.dummyHead}${codec.dummyEnd}${btoa("example data")}`;
return {
_id: "entry-id",
_rev: "1-a",
path: v2Path,
type: "plain",
datatype: "plain",
data,
ctime: 10,
mtime: 20,
size: data.length,
children: [],
eden: {},
} as unknown as LoadedEntry;
}
function createOperations() {
const settings = { usePluginSync: true, usePluginSyncV2: true };
const database = {
findEntries: vi.fn(async function* () {
// No refresh entries are needed by the focused V2 test.
}),
getDBEntry: vi.fn(async () => loadedEntry()),
putDBEntry: vi.fn(async () => ({ ok: true, id: "entry-id", rev: "2-b" })),
};
const deleteConfigOnDatabase = vi.fn(
async (): Promise<SnapshotPersistenceResult<boolean>> => ({
value: true,
status: "missing",
refreshes: [],
})
);
const snapshotPersistence = {
deleteConfigOnDatabase,
};
const dependencies: CatalogueOperationsDependencies = {
getSettings: () => settings,
getLocalDatabase: () => database,
path: {
getPath: (entry) => entry.path,
path2id: async (path) => path as unknown as DocumentID,
},
log: vi.fn(),
snapshotPersistence,
publishScanCount: vi.fn(),
};
return {
database,
dependencies,
settings,
operations: new CatalogueOperations(dependencies),
snapshotPersistence,
};
}
describe("Customisation Sync catalogue operations", () => {
beforeEach(() => {
testState.processors.length = 0;
testState.reactiveSources.length = 0;
vi.clearAllMocks();
});
it("owns and releases the active shared scan processor and its progress subscription", () => {
const { dependencies, operations } = createOperations();
operations.enumerationActive.set(true);
operations.dispose();
expect(testState.processors).toHaveLength(1);
expect(testState.processors[0].terminate).toHaveBeenCalledOnce();
expect(testState.reactiveSources[0].offChanged).toHaveBeenCalledOnce();
expect(get(operations.enumerationActive)).toBe(false);
expect(dependencies.publishScanCount).toHaveBeenCalledWith(0);
});
it("chooses loading or migration when queued work starts", async () => {
const { operations, settings } = createOperations();
const migrate = vi
.spyOn(
operations as unknown as { migrateV1ToV2: (showMessage: boolean, entry: AnyEntry) => Promise<void> },
"migrateV1ToV2"
)
.mockResolvedValue(undefined);
const entry = { path: v1Path, deleted: false } as AnyEntry;
settings.usePluginSyncV2 = false;
await testState.processors[0].process([entry]);
expect(migrate).not.toHaveBeenCalled();
settings.usePluginSyncV2 = true;
await testState.processors[0].process([entry]);
expect(migrate).toHaveBeenCalledOnce();
operations.dispose();
});
it("keeps V2 row publication delayed behind the process-global refresh task", async () => {
const { operations } = createOperations();
await operations.updatePluginListV2(false, v2Path);
expect(get(operations.catalogue)).toEqual([]);
expect(scheduleTask).toHaveBeenCalledWith("updatePluginListV2", 100, expect.any(Function));
const publish = vi.mocked(scheduleTask).mock.calls[0]?.[2] as (() => void) | undefined;
publish?.();
expect(get(operations.catalogue)).toHaveLength(1);
expect(get(operations.catalogue)[0]).toMatchObject({
documentPath: "ix:device-a/PLUGIN_DATA/example.md",
files: [{ filename: "plugins/example/data.json" }],
});
operations.dispose();
});
it("uses the persistence deletion outcome and explicitly awaits migration refresh", async () => {
const { database, operations, snapshotPersistence } = createOperations();
const loadedV1 = {
...loadedEntry(),
path: v1Path,
data: codec.serialize({
category: "CONFIG",
name: "app.json",
term: "device-a",
files: [{ filename: "app.json", data: [btoa("config")], mtime: 10, size: 6 }],
mtime: 10,
}),
} as LoadedEntry;
database.getDBEntry.mockResolvedValue(loadedV1);
snapshotPersistence.deleteConfigOnDatabase.mockResolvedValue({
value: true,
status: "deleted",
refreshes: [{ mode: "v1", timing: "await", path: v1Path }],
});
const refresh = vi.spyOn(operations, "updatePluginList").mockResolvedValue(undefined);
await (
operations as unknown as {
migrateV1ToV2(showMessage: boolean, entry: LoadedEntry): Promise<void>;
}
).migrateV1ToV2(false, { path: v1Path, deleted: false } as LoadedEntry);
expect(database.putDBEntry).toHaveBeenCalledOnce();
expect(snapshotPersistence.deleteConfigOnDatabase).toHaveBeenCalledWith(v1Path);
expect(refresh).toHaveBeenCalledWith(false, v1Path);
operations.dispose();
});
});
+153
View File
@@ -0,0 +1,153 @@
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { writable } from "svelte/store";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { IPluginDataExDisplay } from "./customisationSyncView.ts";
/**
* Owns the transient catalogue projection used by Customisation Sync.
*
* The database and storage operations remain in the catalogue modules. This
* owner only coordinates the in-memory rows, their reactive publications,
* manifest lookup, and the update counter which is derived from those
* operations.
*/
export class CatalogueState {
private catalogueRows: IPluginDataExDisplay[] = [];
private readonly manifestByKey = new Map<string, PluginManifest>();
private readonly loadedManifestMTimeByKey = new Map<string, number>();
private activeUpdateCount = 0;
readonly catalogue = writable<IPluginDataExDisplay[]>([]);
readonly migrationProgress = writable(0);
readonly manifests = writable(this.manifestByKey);
/** The current manifest lookup passed to V2 display rows. */
get manifestLookup(): ReadonlyMap<string, PluginManifest> {
return this.manifestByKey;
}
/** The current loaded-manifest cache, exposed read-only for diagnostics. */
get loadedManifestMTime(): ReadonlyMap<string, number> {
return this.loadedManifestMTimeByKey;
}
/** Returns the authoritative row for a document path, when present. */
findPlugin(documentPath: FilePathWithPrefix | string): IPluginDataExDisplay | undefined {
return this.catalogueRows.find((entry) => entry.documentPath == documentPath);
}
/** Returns every row matching a document path, preserving legacy duplicates. */
findPlugins(documentPath: FilePathWithPrefix | string): readonly IPluginDataExDisplay[] {
return this.catalogueRows.filter((entry) => entry.documentPath == documentPath);
}
/** Replaces a V1 row and publishes it immediately, preserving legacy order. */
replacePlugin(plugin: IPluginDataExDisplay): void {
const newList = this.catalogueRows.filter((entry) => entry.documentPath != plugin.documentPath);
newList.push(plugin);
this.catalogueRows = newList;
this.catalogue.set(newList);
}
/**
* Replaces a V2 row without publishing it. V2 callers publish through the
* existing delayed task after a cohesive row update has completed.
*/
private replaceV2Plugin(plugin: PluginDataExDisplayV2): void {
const newList = this.catalogueRows.filter((entry) => entry.documentPath != plugin.documentPath);
newList.push(plugin);
this.catalogueRows = newList;
}
/** Applies one loaded or removed V2 file and replaces its catalogue row. */
async updateV2Plugin(
plugin: PluginDataExDisplayV2,
file: Parameters<PluginDataExDisplayV2["setFile"]>[0] | false,
missingFilePath: string
): Promise<void> {
if (file) {
await plugin.setFile(file);
} else {
plugin.deleteFile(missingFilePath);
}
this.replaceV2Plugin(plugin);
}
/** Publishes the current V2 row set when the legacy delayed task fires. */
publishCatalogue(): void {
this.catalogue.set(this.catalogueRows);
}
/**
* Clears rows and loaded manifest mtimes for an explicit reload. The
* manifest map intentionally survives this narrower refresh.
*/
clearForReload(): void {
this.catalogueRows = [];
this.loadedManifestMTimeByKey.clear();
this.catalogue.set(this.catalogueRows);
}
/** Clears only the catalogue rows for a disabled refresh. */
clearForDisabledRefresh(): void {
this.catalogueRows = [];
this.catalogue.set(this.catalogueRows);
}
/** Begins one catalogue update and publishes its progress count. */
beginUpdate(): void {
this.activeUpdateCount++;
this.migrationProgress.set(this.activeUpdateCount);
}
/** Ends one catalogue update and publishes its progress count. */
endUpdate(): void {
this.activeUpdateCount--;
this.migrationProgress.set(this.activeUpdateCount);
}
/**
* Applies a manifest according to the inherited cache rules. A manifest is
* parsed only when no manifest has previously been accepted for the key;
* failed parses still record their mtime, while a later mtime never
* replaces a successfully parsed first manifest.
*/
processManifest(
confKey: string,
mtime: number,
parseManifest: () => PluginManifest,
onParseError: (error: unknown) => void = () => undefined
): void {
let publishCatalogue = false;
if (this.loadedManifestMTimeByKey.get(confKey) != mtime && this.manifestByKey.get(confKey) == undefined) {
try {
this.setManifest(confKey, parseManifest());
this.applyLoadedManifest(confKey);
publishCatalogue = true;
} catch (error) {
onParseError(error);
}
this.loadedManifestMTimeByKey.set(confKey, mtime);
} else {
this.applyLoadedManifest(confKey);
publishCatalogue = true;
}
if (publishCatalogue) this.catalogue.set(this.catalogueRows);
}
private setManifest(key: string, manifest: PluginManifest): void {
const old = this.manifestByKey.get(key);
if (old && !isObjectDifferent(manifest, old)) return;
this.manifestByKey.set(key, manifest);
this.manifests.set(this.manifestByKey);
}
private applyLoadedManifest(confKey: string): void {
this.catalogueRows
.filter((entry) => entry instanceof PluginDataExDisplayV2 && entry.confKey == confKey)
.forEach((entry) => (entry as PluginDataExDisplayV2).applyLoadedManifest());
}
}
@@ -0,0 +1,127 @@
import { get } from "svelte/store";
import { describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { CatalogueState } from "./catalogueState.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
function display(documentPath = "ix:device-a/PLUGIN_MAIN/example.md"): IPluginDataExDisplay {
return {
documentPath: documentPath as FilePathWithPrefix,
category: "PLUGIN_MAIN",
name: "example",
term: "device-a",
files: [],
mtime: 1,
};
}
function file(filename: string, mtime: number): LoadedEntryPluginDataExFile {
return {
path: `ix:device-a/PLUGIN_MAIN/example%${filename}` as FilePathWithPrefix,
filename,
mtime,
data: [filename],
size: filename.length,
} as LoadedEntryPluginDataExFile;
}
describe("Customisation Sync catalogue state", () => {
it("publishes V1 replacement and keeps V2 replacement delayed", async () => {
const state = new CatalogueState();
const setCatalogue = vi.spyOn(state.catalogue, "set");
const row = display();
state.replacePlugin(row);
expect(get(state.catalogue)).toEqual([row]);
expect(setCatalogue).toHaveBeenCalledOnce();
const v2 = new PluginDataExDisplayV2(
{
...display(),
files: [file("main.js", 1)],
},
state.manifestLookup
);
await state.updateV2Plugin(v2, file("main.js", 2), "main.js");
expect(get(state.catalogue)).toEqual([row]);
expect(state.findPlugin(row.documentPath)).toBe(v2);
state.publishCatalogue();
expect(get(state.catalogue)).toEqual([v2]);
});
it("retains the first parsed manifest and records failed mtimes", () => {
const state = new CatalogueState();
const first = { name: "First", version: "1.0.0" } as PluginManifest;
const parseManifest = vi.fn(() => first);
state.processManifest("device-a/plugins/example", 20, parseManifest);
state.processManifest(
"device-a/plugins/example",
30,
() => ({ name: "Second", version: "2.0.0" }) as PluginManifest
);
expect(state.manifestLookup.get("device-a/plugins/example")).toBe(first);
expect(state.loadedManifestMTime.get("device-a/plugins/example")).toBe(20);
expect(parseManifest).toHaveBeenCalledOnce();
const failedState = new CatalogueState();
const onParseError = vi.fn();
const failure = new SyntaxError("invalid");
failedState.processManifest(
"device-a/plugins/failure",
40,
() => {
throw failure;
},
onParseError
);
failedState.processManifest("device-a/plugins/failure", 40, () => first, onParseError);
expect(onParseError).toHaveBeenCalledWith(failure);
expect(failedState.loadedManifestMTime.get("device-a/plugins/failure")).toBe(40);
expect(failedState.manifestLookup.has("device-a/plugins/failure")).toBe(false);
});
it("clears rows and loaded mtimes on reload while retaining manifest lookup", () => {
const state = new CatalogueState();
const key = "device-a/plugins/example";
state.processManifest(key, 20, () => ({ name: "Example" }) as PluginManifest);
state.replacePlugin(display());
state.clearForReload();
expect(get(state.catalogue)).toEqual([]);
expect(state.loadedManifestMTime.size).toBe(0);
expect(state.manifestLookup.get(key)).toEqual({ name: "Example" });
expect(get(state.catalogue)).toEqual([]);
});
it("keeps manifest caches through the narrower disabled refresh", () => {
const state = new CatalogueState();
const key = "device-a/plugins/example";
state.processManifest(key, 20, () => ({ name: "Example" }) as PluginManifest);
state.replacePlugin(display());
state.clearForDisabledRefresh();
expect(get(state.catalogue)).toEqual([]);
expect(state.loadedManifestMTime.get(key)).toBe(20);
expect(state.manifestLookup.has(key)).toBe(true);
});
it("tracks V2 updates through migration progress", () => {
const state = new CatalogueState();
state.beginUpdate();
state.beginUpdate();
expect(get(state.migrationProgress)).toBe(2);
state.endUpdate();
state.endUpdate();
expect(get(state.migrationProgress)).toBe(0);
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { AnyEntry, LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { CatalogueState } from "./catalogueState.ts";
import { loadCustomisationDisplayData, type CustomisationSyncReadCodec } from "./customisationSyncReadOperations.ts";
type CatalogueV1Database = Pick<LiveSyncLocalDB, "getDBEntry" | "putDBEntry">;
export type CatalogueV1Dependencies = {
getLocalDatabase(): CatalogueV1Database;
path: Pick<IPathService, "getPath">;
log: LogFunction;
state: CatalogueState;
};
/** Loads and publishes legacy V1 catalogue rows. */
export class CatalogueV1 {
constructor(private readonly dependencies: CatalogueV1Dependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
async load(entry: AnyEntry, codec: CustomisationSyncReadCodec): Promise<void> {
const path = entry.path || this.dependencies.path.getPath(entry);
const oldEntry = this.dependencies.state.findPlugin(path);
if (oldEntry && oldEntry.mtime == entry.mtime) return;
try {
const pluginData = await loadCustomisationDisplayData(
{
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
path: this.dependencies.path,
log: this.dependencies.log,
},
path,
codec
);
if (pluginData) {
this.dependencies.state.replacePlugin(pluginData);
}
// Failed to load
} catch (ex) {
this._log(`Something happened at enumerating customization :${path}`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
}
+123
View File
@@ -0,0 +1,123 @@
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix, LoadedEntry, LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { scheduleTask } from "@/common/utils.ts";
import { CatalogueState } from "./catalogueState.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import { parseCustomisationSyncV2DocumentPath } from "./customisationSyncPaths.ts";
import { decodeCustomisationSyncV2File, loadCustomisationV2Entry } from "./customisationSyncReadOperations.ts";
import type { LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
type CatalogueV2Database = Pick<LiveSyncLocalDB, "getDBEntry">;
export type CatalogueV2Dependencies = {
getLocalDatabase(): CatalogueV2Database;
log: LogFunction;
state: CatalogueState;
codec: { dummyEnd: string };
};
/** Builds, updates, and publishes V2 catalogue rows and manifests. */
export class CatalogueV2 {
constructor(private readonly dependencies: CatalogueV2Dependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
get manifestLookup() {
return this.dependencies.state.manifestLookup;
}
async createPluginDataExFileV2(
unifiedPathV2: FilePathWithPrefix,
loaded?: LoadedEntry
): Promise<false | LoadedEntryPluginDataExFile> {
// Compatibility: a caller-supplied entry bypasses the database lookup
// and the isLoadedEntry check performed by loadCustomisationV2Entry.
const loadedEntry =
loaded ??
(await loadCustomisationV2Entry(
{
getLocalDatabase: () => this.dependencies.getLocalDatabase(),
log: this.dependencies.log,
},
unifiedPathV2
));
if (!loadedEntry) return false;
const { confKey, file, isManifest } = decodeCustomisationSyncV2File(
unifiedPathV2,
loadedEntry,
this.dependencies.codec.dummyEnd
);
if (isManifest) {
this.dependencies.state.processManifest(
confKey,
file.mtime,
() => JSON.parse(file.data[0]) as PluginManifest,
(error) => {
this._log(
`The file ${loadedEntry.path} seems to manifest, but could not be decoded as JSON`,
LOG_LEVEL_VERBOSE
);
this._log(error, LOG_LEVEL_VERBOSE);
}
);
}
return file;
}
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix): PluginDataExDisplayV2 | undefined {
const { category, device, key, pathV1 } = parseCustomisationSyncV2DocumentPath(unifiedPathV2);
if (category == "") return;
return new PluginDataExDisplayV2(
{
documentPath: pathV1,
category,
name: key,
term: `${device}`,
files: [],
mtime: 0,
},
this.dependencies.state.manifestLookup
);
}
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
// The public parameter is retained for the established catalogue
// signature; V2 publication has never used it.
void showMessage;
try {
this.dependencies.state.beginUpdate();
const { pathV1 } = parseCustomisationSyncV2DocumentPath(unifiedFilenameWithKey);
const oldEntry = this.dependencies.state.findPlugin(pathV1);
let entry: PluginDataExDisplayV2 | undefined;
// Compatibility question: when a V1 row is found first for this
// logical path, the inherited implementation constructs a fresh
// V2 row rather than looking for another existing V2 row. Preserve
// that selection until mixed-format catalogue races are covered.
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
entry = this.createPluginDataFromV2(unifiedFilenameWithKey);
} else {
entry = oldEntry;
}
if (!entry) return;
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
// Compatibility: the inherited update always re-adds an empty V2
// row after deleting its final file.
await this.dependencies.state.updateV2Plugin(entry, file, unifiedFilenameWithKey);
scheduleTask("updatePluginListV2", 100, () => {
this.dependencies.state.publishCatalogue();
});
} finally {
this.dependencies.state.endUpdate();
}
}
}
@@ -0,0 +1,209 @@
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
const FIELD_DELIMITER = "\u200b";
const LINE_DELIMITER = "\n";
export type PluginDataExFile = {
filename: string;
data: string[];
mtime: number;
size: number;
version?: string;
hash?: string;
displayName?: string;
};
export type PluginDataEx = {
documentPath?: FilePathWithPrefix;
category: string;
name: string;
displayName?: string;
term: string;
files: PluginDataExFile[];
version?: string;
mtime: number;
};
export type CustomisationSyncCodecDependencies = {
digestHash(data: string[]): string;
parseYaml(source: string): unknown;
};
function splitWithDelimiters(sources: string[]): string[] {
const result: string[] = [];
for (const str of sources) {
let startIndex = 0;
const maxLen = str.length;
let i = -1;
let fieldIndex;
let lineIndex;
do {
fieldIndex = str.indexOf(FIELD_DELIMITER, startIndex);
lineIndex = str.indexOf(LINE_DELIMITER, startIndex);
if (fieldIndex == -1 && lineIndex == -1) {
break;
}
if (fieldIndex == -1) {
i = lineIndex;
} else if (lineIndex == -1) {
i = fieldIndex;
} else {
i = fieldIndex < lineIndex ? fieldIndex : lineIndex;
}
result.push(str.slice(startIndex, i + 1));
startIndex = i + 1;
} while (i < maxLen);
if (startIndex < maxLen) {
result.push(str.slice(startIndex));
}
}
// Preserve the legacy trailing-empty-chunk behaviour.
if (sources[sources.length - 1] == "") {
result.push("");
}
return result;
}
function getTokenizer(source: string[]) {
const sources = splitWithDelimiters(source);
sources[0] = sources[0].substring(1);
let pos = 0;
let lineRunOut = false;
return {
next(): string {
if (lineRunOut) {
return "";
}
if (pos >= sources.length) {
return "";
}
const item = sources[pos];
if (!item.endsWith(LINE_DELIMITER)) {
pos++;
} else {
lineRunOut = true;
}
if (item.endsWith(FIELD_DELIMITER) || item.endsWith(LINE_DELIMITER)) {
return item.substring(0, item.length - 1);
}
return item + this.next();
},
nextLine() {
if (lineRunOut) {
pos++;
} else {
while (!sources[pos].endsWith(LINE_DELIMITER)) {
pos++;
if (pos >= sources.length) break;
}
pos++;
}
lineRunOut = false;
},
};
}
function deserializeCustomFormat(source: string[]): PluginDataEx {
const tokens = getTokenizer(source);
const category = tokens.next();
const name = tokens.next();
const term = tokens.next();
tokens.nextLine();
const version = tokens.next();
tokens.nextLine();
const mtime = Number(tokens.next());
tokens.nextLine();
const result: PluginDataEx = {
category,
name,
term,
version,
mtime,
files: [],
};
let filename = "";
do {
filename = tokens.next();
if (!filename) break;
const displayName = tokens.next();
const fileVersion = tokens.next();
tokens.nextLine();
const fileMtime = Number(tokens.next());
const size = Number(tokens.next());
const hash = tokens.next();
tokens.nextLine();
const data: string[] = [];
let piece = "";
do {
piece = tokens.next();
if (piece == "") break;
data.push(piece);
} while (piece != "");
result.files.push({
filename,
displayName,
version: fileVersion,
mtime: fileMtime,
size,
data,
hash,
});
tokens.nextLine();
} while (filename);
return result;
}
export function createCustomisationSyncCodec(dependencies: CustomisationSyncCodecDependencies) {
function serialize(data: PluginDataEx): string {
// Group fields with similar entropy around newlines to retain the existing chunking characteristics.
let result = ":";
result += data.category + FIELD_DELIMITER + data.name + FIELD_DELIMITER + data.term + LINE_DELIMITER;
result += (data.version ?? "") + LINE_DELIMITER;
result += data.mtime + LINE_DELIMITER;
for (const file of data.files) {
result +=
file.filename +
FIELD_DELIMITER +
(file.displayName ?? "") +
FIELD_DELIMITER +
(file.version ?? "") +
LINE_DELIMITER;
const hash = dependencies.digestHash(file.data ?? []);
result += file.mtime + FIELD_DELIMITER + file.size + FIELD_DELIMITER + hash + LINE_DELIMITER;
for (const piece of file.data ?? []) {
result += piece + FIELD_DELIMITER;
}
result += LINE_DELIMITER;
}
return result;
}
function deserialize<T>(source: string[], defaultValue: T): T {
try {
if (source[0][0] == ":") {
return deserializeCustomFormat(source) as T;
}
return JSON.parse(source.join("")) as T;
} catch {
try {
return dependencies.parseYaml(source.join("")) as T;
} catch {
return defaultValue;
}
}
}
const dummyHead = serialize({
category: "CONFIG",
name: "migrated",
files: [],
mtime: 0,
term: "-",
displayName: "MIRAGED",
});
const dummyEnd = FIELD_DELIMITER + LINE_DELIMITER + "\u200c";
return { serialize, deserialize, dummyHead, dummyEnd };
}
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
function createCodec() {
const digestHash = vi.fn((data: string[]) => `digest:${data.join("|")}`);
const parseYaml = vi.fn((_source: string): unknown => {
throw new Error("Invalid YAML");
});
return {
codec: createCustomisationSyncCodec({ digestHash, parseYaml }),
digestHash,
parseYaml,
};
}
const data: PluginDataEx = {
category: "PLUGIN_DATA",
name: "example",
term: "device-a",
version: "1.2.3",
mtime: 123,
files: [
{
filename: ".obsidian/plugins/example/data.json",
displayName: "data.json",
version: "2.0.0",
mtime: 120,
size: 6,
data: ["YWJj", "ZGVm"],
},
],
};
describe("compatibility: Customisation Sync codec", () => {
it("preserves the existing custom wire format", () => {
const { codec, digestHash } = createCodec();
expect(codec.serialize(data)).toBe(
":PLUGIN_DATA\u200bexample\u200bdevice-a\n" +
"1.2.3\n" +
"123\n" +
".obsidian/plugins/example/data.json\u200bdata.json\u200b2.0.0\n" +
"120\u200b6\u200bdigest:YWJj|ZGVm\n" +
"YWJj\u200bZGVm\u200b\n"
);
expect(digestHash).toHaveBeenCalledWith(["YWJj", "ZGVm"]);
});
it("round-trips the custom format across arbitrary source chunks", () => {
const { codec } = createCodec();
const serialised = codec.serialize(data);
const source = [serialised.slice(0, 13), serialised.slice(13, 47), serialised.slice(47), ""];
expect(codec.deserialize<PluginDataEx>(source, {} as PluginDataEx)).toEqual({
...data,
files: [
{
...data.files[0],
hash: "digest:YWJj|ZGVm",
},
],
});
});
it("retains JSON as the first legacy fallback", () => {
const { codec, parseYaml } = createCodec();
expect(codec.deserialize(['{"value":1}'], { value: 0 })).toEqual({ value: 1 });
expect(parseYaml).not.toHaveBeenCalled();
});
it("uses the injected YAML parser after JSON parsing fails", () => {
const parseYaml = vi.fn(() => ({ value: 2 }));
const codec = createCustomisationSyncCodec({ digestHash: vi.fn(() => "hash"), parseYaml });
expect(codec.deserialize(["value: 2"], { value: 0 })).toEqual({ value: 2 });
expect(parseYaml).toHaveBeenCalledWith("value: 2");
});
it("returns the supplied default when every decoder rejects the input", () => {
const { codec } = createCodec();
const defaultValue = { retained: true };
expect(codec.deserialize([], defaultValue)).toBe(defaultValue);
});
it("preserves the V2 migration sentinels", () => {
const { codec } = createCodec();
expect(codec.dummyHead).toBe(":CONFIG\u200bmigrated\u200b-\n\n0\n");
expect(codec.dummyEnd).toBe("\u200b\n\u200c");
});
});
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { cancelTask, scheduleTask } from "@/common/utils.ts";
import { CustomisationSyncContext } from "./customisationSyncContext";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
describe("CustomisationSyncContext commands", () => {
it("opens the host-owned dialogue from a scheduled configuration Notice", async () => {
const control = {
open: vi.fn(),
close: vi.fn(),
isOpen: vi.fn(() => false),
};
const showConfigurationNotice = vi.fn();
const updatePluginList = vi.fn(async () => undefined);
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({
getUIControl: () => control,
getSettings: () => ({ usePluginSync: true, notifyPluginOrSettingUpdated: true }) as never,
showConfigurationNotice,
}),
updatePluginList,
});
await configSync.serviceHandlers.processVirtualDocument({
_id: "ix:example",
path: "ix:example",
} as never);
const scheduledNotice = vi.mocked(scheduleTask).mock.calls[0]?.[2] as (() => void) | undefined;
expect(scheduledNotice).toBeTypeOf("function");
scheduledNotice?.();
const openDialogue = showConfigurationNotice.mock.calls[0]?.[0] as (() => void) | undefined;
expect(openDialogue).toBeTypeOf("function");
openDialogue?.();
expect(control.open).toHaveBeenCalledOnce();
expect(updatePluginList).toHaveBeenCalledWith(false, "ix:example");
});
it("delegates catalogue resource release during disposal", () => {
const hideConfigurationNotice = vi.fn();
const periodicPluginSweepProcessor = { disable: vi.fn() };
const catalogueOperations = { dispose: vi.fn() };
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({
hideConfigurationNotice,
}),
periodicPluginSweepProcessor,
catalogueOperations,
});
configSync.dispose();
expect(cancelTask).toHaveBeenCalledWith("config-sync:updated-configuration");
expect(hideConfigurationNotice).toHaveBeenCalledOnce();
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
expect(catalogueOperations.dispose).toHaveBeenCalledOnce();
});
it("characterises the inherited setting-realisation gates pending separate review", async () => {
const isReady = vi.fn(() => false);
const isSuspended = vi.fn(() => false);
const periodicPluginSweepProcessor = { disable: vi.fn(), enable: vi.fn() };
const scanAllConfigFiles = vi.fn(async () => undefined);
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({ isReady, isSuspended }),
periodicPluginSweepProcessor,
scanAllConfigFiles,
});
await expect(configSync.serviceHandlers.onRealiseSetting()).resolves.toBe(true);
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
expect(isReady).not.toHaveBeenCalled();
expect(isSuspended).toHaveBeenCalledOnce();
expect(scanAllConfigFiles).not.toHaveBeenCalled();
expect(periodicPluginSweepProcessor.enable).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
import { get } from "svelte/store";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {
disable = vi.fn();
enable = vi.fn();
},
}));
vi.mock("octagonal-wheels/concurrency/processor", () => ({
QueueProcessor: class QueueProcessor {
clearQueue = vi.fn();
enqueue = vi.fn();
terminate = vi.fn();
startPipeline() {
return this;
}
},
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
describe("CustomisationSyncContext composition", () => {
it("does not share catalogue or presentation state between context instances", () => {
const first = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
const second = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
expect(first.catalogue).not.toBe(second.catalogue);
expect(first.enumerationActive).not.toBe(second.enumerationActive);
expect(first.migrationProgress).not.toBe(second.migrationProgress);
expect(first.manifests).not.toBe(second.manifests);
expect(get(first.manifests)).not.toBe(get(second.manifests));
});
it("exposes frozen semantic service and testing views without writable state", () => {
const context = new CustomisationSyncContext(createCustomisationSyncTestDependencies());
expect(Object.isFrozen(context.serviceHandlers)).toBe(true);
expect(Object.keys(context.serviceHandlers).sort()).toEqual(
[
"enableOptionalFeature",
"onBeforeReplicate",
"onDatabaseInitialised",
"onRealiseSetting",
"onResuming",
"processOptionalFileEvent",
"processVirtualDocument",
"suspendExtraSync",
].sort()
);
expect(Object.isFrozen(context.testing)).toBe(true);
expect(Object.keys(context.testing).sort()).toEqual(
[
"applyDataV2",
"configDir",
"createPluginDataExFileV2",
"createPluginDataFromV2",
"deleteConfigOnDatabase",
"scanAllConfigFiles",
"scanInternalFiles",
"storeCustomizationFiles",
].sort()
);
expect("catalogue" in context.testing).toBe(false);
expect("enumerationActive" in context.testing).toBe(false);
expect("manifests" in context.testing).toBe(false);
context.dispose();
});
});
@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { scheduleTask } from "@/common/utils.ts";
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
import { CustomisationSyncRecentEventDeduplicator } from "./customisationSyncRecentEventDeduplicator.ts";
const PATH = ".obsidian/plugins/example/data.json" as FilePath;
function createConfigSync(options: { ready?: boolean; suspended?: boolean; enabled?: boolean; owned?: boolean } = {}) {
const settings = {
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
pluginSyncExtendedSetting: {},
};
const ownsLocalFile = vi.fn(() => options.owned ?? true);
const statHidden = vi.fn(async () => ({ type: "file", mtime: 1 }));
const recentProcessedInternalFiles = new CustomisationSyncRecentEventDeduplicator();
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({
getConfigDir: () => ".obsidian",
getSettings: () => settings as never,
storageAccess: { statHidden } as never,
ownsLocalFile,
}),
_isMainReady: vi.fn(() => options.ready ?? true),
_isMainSuspended: vi.fn(() => options.suspended ?? false),
isThisModuleEnabled: vi.fn(() => options.enabled ?? true),
pathOperations: {
isTargetPath: vi.fn((path: FilePath) => path == PATH),
filenameToUnifiedKey: vi.fn(() => "ix:device-a/PLUGIN_DATA/example.md"),
},
recentProcessedInternalFiles,
_log: vi.fn(),
});
return { configSync, ownsLocalFile, statHidden };
}
describe("Customisation Sync raw-event admission", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("schedules a recognised path granted by the composition owner", async () => {
const { configSync, ownsLocalFile } = createConfigSync();
await expect(configSync.serviceHandlers.processOptionalFileEvent(PATH)).resolves.toBe(true);
expect(ownsLocalFile).toHaveBeenCalledWith(PATH);
expect(scheduleTask).toHaveBeenCalledOnce();
});
it("rejects an event while the host is not ready", async () => {
const { configSync, statHidden } = createConfigSync({ ready: false });
await expect(configSync.serviceHandlers.processOptionalFileEvent(PATH)).resolves.toBe(false);
expect(statHidden).not.toHaveBeenCalled();
expect(scheduleTask).not.toHaveBeenCalled();
});
it("rejects a path outside the recognised Customisation Sync categories", async () => {
const { configSync, ownsLocalFile } = createConfigSync();
await expect(
configSync.serviceHandlers.processOptionalFileEvent(".obsidian/workspace" as FilePath)
).resolves.toBe(false);
expect(ownsLocalFile).not.toHaveBeenCalled();
expect(scheduleTask).not.toHaveBeenCalled();
});
it("rejects a recognised path assigned to another owner", async () => {
const { configSync, statHidden } = createConfigSync({ owned: false });
await expect(configSync.serviceHandlers.processOptionalFileEvent(PATH)).resolves.toBe(false);
expect(statHidden).not.toHaveBeenCalled();
expect(scheduleTask).not.toHaveBeenCalled();
});
it.each([
["suspended", { suspended: true }],
["disabled", { enabled: false }],
] as const)("rejects an event while %s", async (_label, options) => {
const { configSync } = createConfigSync(options);
await expect(configSync.serviceHandlers.processOptionalFileEvent(PATH)).resolves.toBe(false);
expect(scheduleTask).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
describe("Customisation Sync scan delegation", () => {
it("preserves the public scan argument and result through the focused owner", async () => {
const scanAllConfigFiles = vi.fn(async (_showMessage: boolean) => undefined);
const context = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(context, { scanOperations: { scanAllConfigFiles } });
await expect(context.scanAllConfigFiles(true)).resolves.toBeUndefined();
expect(scanAllConfigFiles).toHaveBeenCalledOnce();
expect(scanAllConfigFiles).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,566 @@
import type PouchDB from "pouchdb-core";
import { normalizePath } from "@/deps.ts";
import type {
EntryDoc,
LoadedEntry,
FilePathWithPrefix,
FilePath,
AnyEntry,
diff_result,
SYNC_MODE,
ObsidianLiveSyncSettings,
LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, MODE_SELECTIVE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ICXHeader, PERIODIC_PLUGIN_SWEEP } from "@/common/types.ts";
import { cancelTask, scheduleTask } from "@/common/utils.ts";
import { $msg } from "@/common/translation";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import type {
CustomisationSyncDialogView,
CustomisationSyncUIControl,
CustomisationSyncServiceHandlers,
CustomisationSyncTestingView,
IPluginDataExDisplay,
LoadedEntryPluginDataExFile,
} from "./customisationSyncView.ts";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { IPathService, IReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import type { OptionalFileSyncFileTreeDependencies } from "@/features/optionalFileSyncFileTree.ts";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import { ApplicationOperations, type ApplicationOperationsDependencies } from "./applicationOperations.ts";
import { CustomisationSyncRecentEventDeduplicator } from "./customisationSyncRecentEventDeduplicator.ts";
import { CatalogueOperations, type CatalogueOperationsDependencies } from "./catalogueOperations.ts";
import { SnapshotPersistence, type SnapshotPersistenceDependencies } from "./snapshotPersistence.ts";
import { SnapshotOperations } from "./snapshotOperations.ts";
import { ScanOperations, type ScanOperationsDependencies } from "./scanOperations.ts";
import {
createCustomisationSyncPathOperations,
type CustomisationSyncPathOperations,
} from "./customisationSyncPathOperations.ts";
export type { PluginDataEx, PluginDataExFile } from "./customisationSyncCodec.ts";
export type {
CustomisationSyncFileCategory,
CustomisationSyncServiceHandlers,
CustomisationSyncTestingView,
IPluginDataExDisplay,
PluginDataExDisplay,
} from "./customisationSyncView.ts";
export { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
type CustomisationSyncSettings = Pick<
ObsidianLiveSyncSettings,
| "usePluginSync"
| "usePluginSyncV2"
| "usePluginEtc"
| "pluginSyncExtendedSetting"
| "autoSweepPlugins"
| "autoSweepPluginsPeriodic"
| "watchInternalFileChanges"
| "notifyPluginOrSettingUpdated"
>;
type CustomisationSyncDatabase = Pick<
LiveSyncLocalDB,
"allDocsRaw" | "findEntries" | "getDBEntry" | "getDBEntryFromMeta" | "getDBEntryMeta" | "putDBEntry" | "putRaw"
>;
type CustomisationSyncStorage = Pick<
StorageAccess,
"ensureDir" | "readHiddenFileBinary" | "readHiddenFileText" | "statHidden" | "writeHiddenFileAuto"
>;
export type CustomisationSyncPeriodicProcessor = {
enable(interval: number): void;
disable(): void;
};
export type CustomisationSyncContextDependencies = OptionalFileSyncFileTreeDependencies & {
getSettings(): CustomisationSyncSettings;
getLocalDatabase(): CustomisationSyncDatabase;
storageAccess: CustomisationSyncStorage;
path: Pick<IPathService, "getPath" | "isMarkedAsSameChanges" | "markChangesAreSame" | "path2id">;
log: LogFunction;
getConfigDir(): string;
getDeviceAndVaultName(): string;
setDeviceAndVaultName(name: string): void;
saveSettingData(): Promise<void>;
applySettings(partial: Partial<ObsidianLiveSyncSettings>, saveImmediately?: boolean): Promise<void>;
replicateUserInitiated: IReplicationService["replicateUserInitiated"];
askString(title: string, key: string, placeholder: string): Promise<string | false>;
isReady(): boolean;
isSuspended(): boolean;
askRestart(): void;
createPeriodicProcessor(process: () => Promise<unknown>): CustomisationSyncPeriodicProcessor;
resolveJsonConflict(
path: FilePath,
files: [LoadedEntryPluginDataExFile, LoadedEntryPluginDataExFile],
remoteName: string,
apply: (content: string) => Promise<boolean>
): Promise<boolean>;
selectTextFile(path: FilePath, diffResult: diff_result, remoteName: string): Promise<"A" | "B" | false>;
reloadPlugin(configDir: string, pluginName: string): Promise<void>;
getFallbackDeviceName(): string;
showConfigurationNotice(openDialog: () => void): void;
hideConfigurationNotice(): void;
getUIControl(): CustomisationSyncUIControl | undefined;
ownsLocalFile(path: FilePath): boolean;
ownsLocalDocument(path: FilePathWithPrefix): boolean;
publishScanCount(count: number): void;
};
export class CustomisationSyncContext implements CustomisationSyncDialogView {
private readonly dependencies: CustomisationSyncContextDependencies;
private readonly pathOperations: CustomisationSyncPathOperations;
private readonly snapshotPersistence: SnapshotPersistence;
private readonly snapshotOperations: SnapshotOperations;
private readonly catalogueOperations: CatalogueOperations;
private readonly applicationOperations: ApplicationOperations;
private readonly scanOperations: ScanOperations;
private readonly recentProcessedInternalFiles = new CustomisationSyncRecentEventDeduplicator();
private serviceHandlersView: CustomisationSyncServiceHandlers | undefined;
private testingView: CustomisationSyncTestingView | undefined;
private readonly periodicPluginSweepProcessor: CustomisationSyncPeriodicProcessor;
constructor(dependencies: CustomisationSyncContextDependencies) {
this.dependencies = dependencies;
this.pathOperations = createCustomisationSyncPathOperations({
getConfigDir: () => dependencies.getConfigDir(),
getUseV2: () => dependencies.getSettings().usePluginSyncV2,
getUsePluginEtc: () => dependencies.getSettings().usePluginEtc,
getDeviceAndVaultName: () => dependencies.getDeviceAndVaultName(),
});
const snapshotPersistenceDependencies: SnapshotPersistenceDependencies = {
getLocalDatabase: () => dependencies.getLocalDatabase(),
storageAccess: dependencies.storageAccess,
path: {
...this.pathOperations,
path2id: (filename, prefix) => dependencies.path.path2id(filename, prefix),
isMarkedAsSameChanges: (file, mtimes) => dependencies.path.isMarkedAsSameChanges(file, mtimes),
markChangesAreSame: (file, newMtime, oldMtime) =>
dependencies.path.markChangesAreSame(file, newMtime, oldMtime),
},
log: (message, level, key) => dependencies.log(message, level, key),
getConfigDir: () => dependencies.getConfigDir(),
};
this.snapshotPersistence = new SnapshotPersistence(snapshotPersistenceDependencies);
this.catalogueOperations = new CatalogueOperations({
getSettings: () => {
const settings = dependencies.getSettings();
return {
usePluginSync: settings.usePluginSync,
usePluginSyncV2: settings.usePluginSyncV2,
};
},
getLocalDatabase: () => dependencies.getLocalDatabase(),
path: {
getPath: (entry) => dependencies.path.getPath(entry),
path2id: (filename, prefix) => dependencies.path.path2id(filename, prefix),
},
log: (message, level, key) => dependencies.log(message, level, key),
snapshotPersistence: this.snapshotPersistence,
publishScanCount: (count) => dependencies.publishScanCount(count),
} satisfies CatalogueOperationsDependencies);
this.snapshotOperations = new SnapshotOperations({
getSettings: () => ({ usePluginSyncV2: dependencies.getSettings().usePluginSyncV2 }),
getDeviceAndVaultName: () => dependencies.getDeviceAndVaultName(),
log: (message, level, key) => dependencies.log(message, level, key),
snapshotPersistence: this.snapshotPersistence,
catalogueOperations: this.catalogueOperations,
});
const applicationOperationsDependencies: ApplicationOperationsDependencies = {
getLocalDatabase: () => ({ getDBEntry: (path) => dependencies.getLocalDatabase().getDBEntry(path) }),
storageAccess: dependencies.storageAccess,
path: {
filenameToUnifiedKey: (path, termOverride) =>
this.pathOperations.filenameToUnifiedKey(path, termOverride),
},
log: (message, level, key) => dependencies.log(message, level, key),
getConfigDir: () => dependencies.getConfigDir(),
getDeviceAndVaultName: () => dependencies.getDeviceAndVaultName(),
resolveJsonConflict: (path, files, remoteName, apply) =>
dependencies.resolveJsonConflict(path, files, remoteName, apply),
selectTextFile: (path, diffResult, remoteName) => dependencies.selectTextFile(path, diffResult, remoteName),
reloadPlugin: (configDir, pluginName) => dependencies.reloadPlugin(configDir, pluginName),
askRestart: () => dependencies.askRestart(),
snapshotOperations: this.snapshotOperations,
catalogueOperations: this.catalogueOperations,
};
this.applicationOperations = new ApplicationOperations(applicationOperationsDependencies);
this.scanOperations = new ScanOperations({
listFiles: async (path) => await dependencies.listFiles(path),
getSettings: () => ({ usePluginSyncV2: dependencies.getSettings().usePluginSyncV2 }),
getLocalDatabase: () => dependencies.getLocalDatabase(),
path: {
getPath: (entry) => dependencies.path.getPath(entry),
isTargetPath: (path) => this.pathOperations.isTargetPath(path),
filenameToUnifiedKey: (path, termOverride) =>
this.pathOperations.filenameToUnifiedKey(path, termOverride),
filenameWithUnifiedKey: (path, termOverride) =>
this.pathOperations.filenameWithUnifiedKey(path, termOverride),
unifiedKeyPrefixOfTerminal: (termOverride) =>
this.pathOperations.unifiedKeyPrefixOfTerminal(termOverride),
},
log: (message, level, key) => dependencies.log(message, level, key),
getConfigDir: () => dependencies.getConfigDir(),
getDeviceAndVaultName: () => dependencies.getDeviceAndVaultName(),
ownsLocalFile: (path) => dependencies.ownsLocalFile(path),
ownsLocalDocument: (path) => dependencies.ownsLocalDocument(path),
snapshotOperations: this.snapshotOperations,
catalogueOperations: this.catalogueOperations,
} satisfies ScanOperationsDependencies);
this.periodicPluginSweepProcessor = dependencies.createPeriodicProcessor(
async () => await this.scanAllConfigFiles(false)
);
}
get catalogue() {
return this.catalogueOperations.catalogue;
}
get enumerationActive() {
return this.catalogueOperations.enumerationActive;
}
get migrationProgress() {
return this.catalogueOperations.migrationProgress;
}
get manifests() {
return this.catalogueOperations.manifests;
}
/**
* Semantic callbacks for registration by the optional-file composition
* feature. The returned object is immutable, and each callback retains its
* context without requiring callers to bind a concrete implementation.
*/
get serviceHandlers(): CustomisationSyncServiceHandlers {
if (!this.serviceHandlersView) {
this.serviceHandlersView = Object.freeze({
processOptionalFileEvent: (path: FilePath) => this.processOptionalFileEvent(path),
processVirtualDocument: (docs: PouchDB.Core.ExistingDocument<EntryDoc>) =>
this.processVirtualDocument(docs),
onRealiseSetting: () => this.realiseSettingSyncMode(),
onResuming: () => this.onResumeProcess(),
onBeforeReplicate: (showMessage: boolean) => this.beforeReplicate(showMessage),
onDatabaseInitialised: (showNotice: boolean) => this.onDatabaseInitialised(showNotice),
suspendExtraSync: () => this.suspendExtraSync(),
enableOptionalFeature: (mode: OptionalSyncFeatureMode) => this.enableOptionalFeature(mode),
});
}
return this.serviceHandlersView;
}
/**
* Narrow internal surface used by maintained real-Obsidian contract tests.
* It intentionally omits the context, queues, and writable stores.
*/
get testing(): CustomisationSyncTestingView {
if (!this.testingView) {
this.testingView = Object.freeze({
configDir: this.configDir,
scanInternalFiles: async () => await this.scanOperations.scanInternalFiles(),
scanAllConfigFiles: async (showMessage: boolean) => await this.scanAllConfigFiles(showMessage),
storeCustomizationFiles: async (path: FilePath, termOverride?: string) =>
await this.snapshotOperations.storeCustomizationFiles(path, termOverride),
deleteConfigOnDatabase: async (path: FilePathWithPrefix, forceWrite?: boolean) =>
await this.snapshotOperations.deleteConfigOnDatabase(path, forceWrite),
createPluginDataFromV2: (path: FilePathWithPrefix) =>
this.catalogueOperations.createPluginDataFromV2(path),
createPluginDataExFileV2: async (path: FilePathWithPrefix, loaded?: LoadedEntry) =>
await this.catalogueOperations.createPluginDataExFileV2(path, loaded),
applyDataV2: async (data: PluginDataExDisplayV2, content?: string) =>
await this.applicationOperations.applyDataV2(data, content),
});
}
return this.testingView;
}
private get configDir() {
return this.dependencies.getConfigDir();
}
private get settings() {
return this.dependencies.getSettings();
}
private get storageAccess() {
return this.dependencies.storageAccess;
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.path.getPath(entry);
}
private _isMainReady() {
return this.dependencies.isReady();
}
private _isMainSuspended() {
return this.dependencies.isSuspended();
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
private get useSyncPluginEtc() {
return this.settings.usePluginEtc;
}
private isThisModuleEnabled() {
return this.settings.usePluginSync;
}
isEnabled(): boolean {
return this.isThisModuleEnabled();
}
getDeviceAndVaultName(): string {
return this.dependencies.getDeviceAndVaultName();
}
getConfiguredModes() {
return Object.values(this.settings.pluginSyncExtendedSetting).map((entry) => ({
...entry,
files: [...entry.files],
}));
}
isPluginEtcEnabled(): boolean {
return this.useSyncPluginEtc;
}
updateConfiguredMode(key: string, mode: SYNC_MODE, files: string[]): void {
if (mode == MODE_SELECTIVE) {
delete this.settings.pluginSyncExtendedSetting[key];
} else {
this.settings.pluginSyncExtendedSetting[key] = {
key,
mode,
files: [...files],
};
}
void this.dependencies.saveSettingData();
}
getConfiguredTargetFiles(key: string): string[] {
const configDir = normalizePath(this.configDir);
return (this.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((path) => `${configDir}/${path}`);
}
async synchronise(): Promise<void> {
await this.dependencies.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
}
askString(title: string, key: string, placeholder: string): Promise<string | false> {
return this.dependencies.askString(title, key, placeholder);
}
async compareFileUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
filename: string
): Promise<boolean> {
return await this.applicationOperations.compareFileUsingDisplayData(dataA, dataB, filename);
}
async duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void> {
await this.applicationOperations.duplicateData(data, deviceName);
}
dispose() {
cancelTask(UPDATED_CONFIGURATION_NOTICE_KEY);
this.periodicPluginSweepProcessor?.disable();
this.catalogueOperations.dispose();
this.dependencies.hideConfigurationNotice();
}
private async onDatabaseInitialised(showNotice: boolean) {
if (!this.isThisModuleEnabled()) return true;
try {
this._log("Scanning customizations...");
await this.scanAllConfigFiles(showNotice);
this._log("Scanning customizations : done");
} catch (ex) {
this._log("Scanning customizations : failed");
this._log(ex, LOG_LEVEL_VERBOSE);
}
return true;
}
private async beforeReplicate(showNotice: boolean) {
if (!this.isThisModuleEnabled()) return true;
if (this.settings.autoSweepPlugins) {
await this.scanAllConfigFiles(showNotice);
return true;
}
return true;
}
private async onResumeProcess(): Promise<boolean> {
if (!this.isThisModuleEnabled()) return true;
if (this._isMainSuspended()) {
return true;
}
if (this.settings.autoSweepPlugins) {
await this.scanAllConfigFiles(false);
}
this.periodicPluginSweepProcessor.enable(
this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges
? PERIODIC_PLUGIN_SWEEP * 1000
: 0
);
return true;
}
async reloadPluginList(showMessage: boolean) {
await this.catalogueOperations.reloadPluginList(showMessage);
}
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
await this.catalogueOperations.updatePluginList(showMessage, updatedDocumentPath);
}
async compareUsingDisplayData(dataA: IPluginDataExDisplay, dataB: IPluginDataExDisplay, compareEach = false) {
return await this.applicationOperations.compareUsingDisplayData(dataA, dataB, compareEach);
}
async applyData(data: IPluginDataExDisplay, content?: string): Promise<boolean> {
return await this.applicationOperations.applyData(data, content);
}
async deleteData(data: IPluginDataExDisplay): Promise<boolean> {
return await this.applicationOperations.deleteData(data);
}
private async processVirtualDocument(docs: PouchDB.Core.ExistingDocument<EntryDoc>) {
if (!docs._id.startsWith(ICXHeader)) return false;
if (this.isThisModuleEnabled()) {
await this.updatePluginList(
false,
(docs as AnyEntry).path ? (docs as AnyEntry).path : this.getPath(docs as AnyEntry)
);
}
if (this.isThisModuleEnabled() && this.settings.notifyPluginOrSettingUpdated) {
if (!this.dependencies.getUIControl()?.isOpen()) {
scheduleTask(UPDATED_CONFIGURATION_NOTICE_KEY, 1000, () => {
this.dependencies.showConfigurationNotice(() => this.dependencies.getUIControl()?.open());
});
}
}
return true;
}
private async realiseSettingSyncMode(): Promise<boolean> {
this.periodicPluginSweepProcessor?.disable();
// Compatibility question: this inherited callback checks the method
// reference rather than invoking it, then proceeds only while the host is
// suspended. Preserve both gates until their intended lifecycle semantics
// are verified and corrected under a separate behavioural test.
if (!this._isMainReady) return true;
if (!this._isMainSuspended()) return true;
if (!this.isThisModuleEnabled()) return true;
if (this.settings.autoSweepPlugins) {
await this.scanAllConfigFiles(false);
}
this.periodicPluginSweepProcessor.enable(
this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges
? PERIODIC_PLUGIN_SWEEP * 1000
: 0
);
return true;
}
private async processOptionalFileEvent(path: FilePath): Promise<boolean> {
return await this.watchVaultRawEventsAsync(path);
}
private async watchVaultRawEventsAsync(path: FilePath) {
if (!this._isMainReady()) return false;
if (this._isMainSuspended()) return false;
if (!this.isThisModuleEnabled()) return false;
if (!this.pathOperations.isTargetPath(path)) return false;
if (!this.dependencies.ownsLocalFile(path)) return false;
const stat = await this.storageAccess.statHidden(path);
// Make sure that target is a file.
if (stat && stat.type != "file") return false;
// this._log(`Customization file detected: ${path}`, LOG_LEVEL_VERBOSE);
const storageMTime = ~~(((stat && stat.mtime) || 0) / 1000);
const key = `${path}-${storageMTime}`;
if (!this.recentProcessedInternalFiles.admit(key)) {
// If recently processed, it may caused by self.
// return true to prevent pass the event to the next.
return true;
}
// To prevent saving half-collected file sets.
const keySchedule = this.pathOperations.filenameToUnifiedKey(path);
scheduleTask(keySchedule, 100, async () => {
await this.snapshotOperations.storeCustomizationFiles(path);
});
// Okay, it may handled after 100ms.
// This was my own job.
return true;
}
async scanAllConfigFiles(showMessage: boolean): Promise<void> {
await this.scanOperations.scanAllConfigFiles(showMessage);
}
private suspendExtraSync(): Promise<boolean> {
if (this.settings.usePluginSync || this.settings.autoSweepPlugins) {
this._log(
"Customisation sync have been temporarily disabled. Please enable them after the fetching, if you need them.",
LOG_LEVEL_NOTICE
);
this.settings.usePluginSync = false;
this.settings.autoSweepPlugins = false;
}
return Promise.resolve(true);
}
private async enableOptionalFeature(mode: OptionalSyncFeatureMode): Promise<boolean> {
await this.configureCustomisationSync(mode);
return true;
}
private async configureCustomisationSync(mode: OptionalSyncFeatureMode) {
if (mode == "DISABLE") {
await this.dependencies.applySettings(
{
usePluginSync: false,
},
true
);
return;
}
if (mode == "CUSTOMIZE") {
if (!this.dependencies.getDeviceAndVaultName()) {
let name = await this.dependencies.askString(
$msg("Device name"),
$msg("Please set this device name"),
`desktop`
);
if (!name) {
name = this.dependencies.getFallbackDeviceName();
}
this.dependencies.setDeviceAndVaultName(name);
}
await this.dependencies.applySettings(
{
usePluginSync: true,
useAdvancedMode: true,
},
true
);
await this.scanAllConfigFiles(true);
}
}
}
@@ -0,0 +1,49 @@
import type { CustomisationSyncContextDependencies } from "./customisationSyncContext.ts";
/** Minimal inert dependency set for focused Customisation Sync unit tests. */
export function createCustomisationSyncTestDependencies(
overrides: Partial<CustomisationSyncContextDependencies> = {}
): CustomisationSyncContextDependencies {
const defaults = {
getSettings: () => ({
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
pluginSyncExtendedSetting: {},
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
watchInternalFileChanges: false,
notifyPluginOrSettingUpdated: false,
}),
getLocalDatabase: () => ({}),
storageAccess: {},
path: {},
log: () => undefined,
getConfigDir: () => ".config-dir",
getDeviceAndVaultName: () => "device-a",
setDeviceAndVaultName: () => undefined,
saveSettingData: () => Promise.resolve(),
applySettings: () => Promise.resolve(),
replicateUserInitiated: () => Promise.resolve(),
askString: () => Promise.resolve(false),
isReady: () => true,
isSuspended: () => false,
askRestart: () => undefined,
createPeriodicProcessor: () => ({
enable: () => undefined,
disable: () => undefined,
}),
listFiles: () => Promise.resolve({ files: [], folders: [] }),
resolveJsonConflict: () => Promise.resolve(false),
selectTextFile: () => Promise.resolve(false),
reloadPlugin: () => Promise.resolve(),
getFallbackDeviceName: () => "desktop-test",
showConfigurationNotice: () => undefined,
hideConfigurationNotice: () => undefined,
getUIControl: () => undefined,
ownsLocalFile: () => true,
ownsLocalDocument: () => true,
publishScanCount: () => undefined,
} as unknown as CustomisationSyncContextDependencies;
return { ...defaults, ...overrides };
}
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
vi.mock("octagonal-wheels/concurrency/processor", () => ({
QueueProcessor: class QueueProcessor {
clearQueue = vi.fn();
enqueue = vi.fn();
terminate = vi.fn();
startPipeline() {
return this;
}
},
}));
import {
LOG_LEVEL_VERBOSE,
type FilePathWithPrefix,
type LoadedEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import { CatalogueOperations } from "./catalogueOperations.ts";
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
const path = "ix:device-a/PLUGIN_MAIN/example%manifest.json" as FilePathWithPrefix;
const confKey = "device-a/plugins/example";
const codec = createCustomisationSyncCodec({
digestHash,
parseYaml: () => undefined,
});
function loadedManifest(manifestSource: string, mtime: number): LoadedEntry {
const data = `${codec.dummyHead}${codec.dummyEnd}${btoa(manifestSource)}`;
return {
_id: "entry-id",
_rev: "1-a",
path,
type: "plain",
datatype: "plain",
data,
ctime: 10,
mtime,
size: data.length,
children: [],
eden: {},
} as unknown as LoadedEntry;
}
function createContext() {
const log = vi.fn();
const snapshotPersistence = {
deleteConfigOnDatabase: vi.fn(async () => ({ value: true, status: "missing" as const, refreshes: [] })),
};
const catalogueOperations = new CatalogueOperations({
...createCustomisationSyncTestDependencies({
log,
getLocalDatabase: () => ({ getDBEntry: async () => false }) as never,
}),
snapshotPersistence,
publishScanCount: vi.fn(),
});
const pluginManifests = catalogueOperations.manifestLookup;
const setManifests = vi.spyOn(catalogueOperations.manifests, "set");
const setCatalogue = vi.spyOn(catalogueOperations.catalogue, "set");
const context = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(context, {
dependencies: createCustomisationSyncTestDependencies({
log,
getLocalDatabase: () => ({ getDBEntry: async () => false }) as never,
}),
catalogueOperations,
});
return {
catalogueOperations,
context,
log,
pluginManifests,
setCatalogue,
setManifests,
};
}
describe("compatibility: Customisation Sync V2 manifest state", () => {
it("keeps the first parsed manifest when a later file has a different mtime", async () => {
const { catalogueOperations, context, pluginManifests, setManifests } = createContext();
await context.testing.createPluginDataExFileV2(
path,
loadedManifest(JSON.stringify({ id: "example", name: "First", version: "1.0.0" }), 20)
);
await context.testing.createPluginDataExFileV2(
path,
loadedManifest(JSON.stringify({ id: "example", name: "Second", version: "2.0.0" }), 30)
);
expect(pluginManifests.get(confKey)).toMatchObject({ name: "First", version: "1.0.0" });
expect(setManifests).toHaveBeenCalledOnce();
catalogueOperations.dispose();
});
it("records a failed manifest mtime and does not retry the same revision", async () => {
const { catalogueOperations, context, log, pluginManifests, setCatalogue } = createContext();
const invalid = loadedManifest("{invalid", 20);
await expect(context.testing.createPluginDataExFileV2(path, invalid)).resolves.toMatchObject({
filename: "plugins/example/manifest.json",
});
await context.testing.createPluginDataExFileV2(path, invalid);
expect(pluginManifests.has(confKey)).toBe(false);
expect(log).toHaveBeenCalledTimes(2);
expect(log).toHaveBeenNthCalledWith(
1,
`The file ${path} seems to manifest, but could not be decoded as JSON`,
LOG_LEVEL_VERBOSE,
undefined
);
expect(log).toHaveBeenNthCalledWith(2, expect.any(SyntaxError), LOG_LEVEL_VERBOSE, undefined);
expect(setCatalogue).toHaveBeenCalledOnce();
catalogueOperations.dispose();
});
});
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import {
type FilePathWithPrefix,
MODE_AUTOMATIC,
MODE_SELECTIVE,
type PluginSyncSettingEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { CustomisationSyncContext } from "./customisationSyncContext.ts";
import { createCustomisationSyncTestDependencies } from "./customisationSyncContext.unit.fixture.ts";
import type { IPluginDataExDisplay } from "./customisationSyncView.ts";
function createConfigSync() {
const saveSettingData = vi.fn(async () => undefined);
const replicateUserInitiated = vi.fn(async () => undefined);
const askString = vi.fn(async () => "device-b" as string | false);
const pluginSyncExtendedSetting: Record<string, PluginSyncSettingEntry> = {
"PLUGIN_DATA/example": {
key: "PLUGIN_DATA/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/data.json"],
},
};
const settings = {
usePluginSync: true,
usePluginSyncV2: true,
usePluginEtc: true,
pluginSyncExtendedSetting,
};
const configSync = Object.create(CustomisationSyncContext.prototype) as CustomisationSyncContext;
Object.assign(configSync, {
dependencies: createCustomisationSyncTestDependencies({
getConfigDir: () => ".obsidian",
getSettings: () => settings as never,
saveSettingData,
replicateUserInitiated: replicateUserInitiated as never,
askString,
}),
});
return { askString, configSync, replicateUserInitiated, saveSettingData, settings };
}
const display = {
documentPath: "ix:device-a/PLUGIN_DATA/example%data.json" as FilePathWithPrefix,
category: "PLUGIN_DATA",
name: "example",
term: "device-a",
files: [
{ filename: "data.json", data: ["a"], mtime: 1, size: 1 },
{ filename: "other.json", data: ["b"], mtime: 2, size: 1 },
],
mtime: 2,
} satisfies IPluginDataExDisplay;
describe("CustomisationSyncContext dialogue view", () => {
it("projects and updates Customisation Sync modes without exposing mutable settings", () => {
const { configSync, saveSettingData, settings } = createConfigSync();
const projected = configSync.getConfiguredModes();
projected[0].files.push("changed-in-view");
expect(settings.pluginSyncExtendedSetting["PLUGIN_DATA/example"].files).toEqual(["plugins/example/data.json"]);
expect(configSync.getConfiguredTargetFiles("PLUGIN_DATA/example")).toEqual([
".obsidian/plugins/example/data.json",
]);
configSync.updateConfiguredMode("PLUGIN_MAIN/example", MODE_AUTOMATIC, ["plugins/example/main.js"]);
expect(settings.pluginSyncExtendedSetting["PLUGIN_MAIN/example"]).toEqual({
key: "PLUGIN_MAIN/example",
mode: MODE_AUTOMATIC,
files: ["plugins/example/main.js"],
});
configSync.updateConfiguredMode("PLUGIN_MAIN/example", MODE_SELECTIVE, []);
expect(settings.pluginSyncExtendedSetting).not.toHaveProperty("PLUGIN_MAIN/example");
expect(saveSettingData).toHaveBeenCalledTimes(2);
});
it("routes host operations through the focused view", async () => {
const { askString, configSync, replicateUserInitiated } = createConfigSync();
await configSync.synchronise();
await expect(configSync.askString("Duplicate", "device name", "")).resolves.toBe("device-b");
expect(replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(askString).toHaveBeenCalledWith("Duplicate", "device name", "");
});
it("delegates file-level comparison and duplication to the application owner", async () => {
const { configSync } = createConfigSync();
const compareFileUsingDisplayData = vi.fn(async () => true);
const duplicateData = vi.fn(async () => undefined);
Object.assign(configSync, {
applicationOperations: { compareFileUsingDisplayData, duplicateData },
});
await expect(configSync.compareFileUsingDisplayData(display, display, "data.json")).resolves.toBe(true);
expect(compareFileUsingDisplayData).toHaveBeenCalledWith(display, display, "data.json");
await configSync.duplicateData(display, "device-b");
expect(duplicateData).toHaveBeenCalledWith(display, "device-b");
});
});
@@ -0,0 +1,67 @@
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isDocContentSame } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { getCustomisationSyncCategoryFolder } from "./customisationSyncPaths.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
export class PluginDataExDisplayV2 {
documentPath: FilePathWithPrefix;
category: string;
term: string;
files: LoadedEntryPluginDataExFile[];
name: string;
confKey: string;
_displayName: string | undefined;
_version: string | undefined;
constructor(
data: IPluginDataExDisplay,
private readonly manifestLookup: ReadonlyMap<string, PluginManifest>
) {
this.documentPath = `${data.documentPath}` as FilePathWithPrefix;
this.category = `${data.category}`;
this.name = `${data.name}`;
this.term = `${data.term}`;
this.files = [...(data.files as LoadedEntryPluginDataExFile[])];
this.confKey = `${getCustomisationSyncCategoryFolder(this.category, this.term)}${this.name}`;
this.applyLoadedManifest();
}
async setFile(file: LoadedEntryPluginDataExFile): Promise<void> {
const old = this.files.find((entry) => entry.filename == file.filename);
if (old) {
if (old.mtime == file.mtime && (await isDocContentSame(old.data, file.data))) return;
this.files = this.files.filter((entry) => entry.filename != file.filename);
}
this.files.push(file);
if (file.filename == "manifest.json") {
this.applyLoadedManifest();
}
}
deleteFile(filename: string): void {
this.files = this.files.filter((entry) => entry.filename != filename);
}
applyLoadedManifest(): void {
const manifest = this.manifestLookup.get(this.confKey);
if (manifest) {
this._displayName = manifest.name;
if (this.category == "PLUGIN_MAIN" || this.category == "THEME") {
this._version = manifest.version;
}
}
}
get displayName(): string {
return this._displayName || this.name;
}
get version(): string | undefined {
return this._version;
}
get mtime(): number {
return ~~this.files.reduce((sum, file) => sum + file.mtime, 0) / this.files.length;
}
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { PluginManifest } from "@/deps.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
import type { IPluginDataExDisplay, LoadedEntryPluginDataExFile } from "./customisationSyncView.ts";
function file(filename: string, mtime: number, data: string[]): LoadedEntryPluginDataExFile {
return { filename, mtime, data, size: data.join("").length } as LoadedEntryPluginDataExFile;
}
function display(files: LoadedEntryPluginDataExFile[] = []): IPluginDataExDisplay {
return {
documentPath: "ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix,
category: "PLUGIN_MAIN",
name: "example",
term: "device-a",
files,
mtime: 0,
};
}
describe("PluginDataExDisplayV2", () => {
it("projects manifest identity and file modification time", () => {
const manifests = new Map([
["device-a/plugins/example", { name: "Example plug-in", version: "1.2.3" } as PluginManifest],
]);
const model = new PluginDataExDisplayV2(
display([file("main.js", 10, ["main"]), file("data.json", 20, ["data"])]),
manifests
);
expect(model.confKey).toBe("device-a/plugins/example");
expect(model.displayName).toBe("Example plug-in");
expect(model.version).toBe("1.2.3");
expect(model.mtime).toBe(15);
});
it("retains an unchanged file and replaces changed content", async () => {
const original = file("main.js", 10, ["same"]);
const model = new PluginDataExDisplayV2(display([original]), new Map());
await model.setFile(file("main.js", 10, ["same"]));
expect(model.files[0]).toBe(original);
const changed = file("main.js", 10, ["changed"]);
await model.setFile(changed);
expect(model.files).toEqual([changed]);
});
it("deletes only the named file", () => {
const retained = file("styles.css", 20, ["css"]);
const model = new PluginDataExDisplayV2(display([file("main.js", 10, ["main"]), retained]), new Map());
model.deleteFile("main.js");
expect(model.files).toEqual([retained]);
});
});
@@ -0,0 +1,59 @@
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createCustomisationSyncDevicePrefix,
createCustomisationSyncV1DocumentPath,
createCustomisationSyncV2DocumentPath,
getCustomisationSyncFileCategory,
isCustomisationSyncTargetPath,
type CustomisationSyncFileCategory,
type CustomisationSyncPathOptions,
} from "./customisationSyncPaths.ts";
/** Live projections needed to derive Customisation Sync paths. */
export type CustomisationSyncPathOperationsDependencies = Readonly<{
getConfigDir: () => string;
getUseV2: () => boolean;
getUsePluginEtc: () => boolean;
getDeviceAndVaultName: () => string;
}>;
/** Path operations exposed to the Customisation Sync context. */
export type CustomisationSyncPathOperations = Readonly<{
getFileCategory(filePath: string): CustomisationSyncFileCategory;
isTargetPath(filePath: string): boolean;
filenameToUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix;
filenameWithUnifiedKey(path: string, termOverride?: string): FilePathWithPrefix;
unifiedKeyPrefixOfTerminal(termOverride?: string): FilePathWithPrefix;
}>;
function getPathOptions(dependencies: CustomisationSyncPathOperationsDependencies): CustomisationSyncPathOptions {
return {
configDir: dependencies.getConfigDir(),
useV2: dependencies.getUseV2(),
usePluginEtc: dependencies.getUsePluginEtc(),
};
}
export function createCustomisationSyncPathOperations(
dependencies: CustomisationSyncPathOperationsDependencies
): CustomisationSyncPathOperations {
return Object.freeze({
getFileCategory: (filePath: string) => getCustomisationSyncFileCategory(filePath, getPathOptions(dependencies)),
isTargetPath: (filePath: string) => isCustomisationSyncTargetPath(filePath, getPathOptions(dependencies)),
filenameToUnifiedKey: (path: string, termOverride?: string) =>
createCustomisationSyncV1DocumentPath(
path,
termOverride || dependencies.getDeviceAndVaultName(),
getPathOptions(dependencies)
),
filenameWithUnifiedKey: (path: string, termOverride?: string) =>
createCustomisationSyncV2DocumentPath(
path,
termOverride || dependencies.getDeviceAndVaultName(),
getPathOptions(dependencies)
),
unifiedKeyPrefixOfTerminal: (termOverride?: string) =>
createCustomisationSyncDevicePrefix(termOverride || dependencies.getDeviceAndVaultName()),
});
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
createCustomisationSyncPathOperations,
type CustomisationSyncPathOperationsDependencies,
} from "./customisationSyncPathOperations.ts";
type PathState = {
configDir: string;
useV2: boolean;
usePluginEtc: boolean;
deviceAndVaultName: string;
};
function createOperations(state: PathState) {
const dependencies: CustomisationSyncPathOperationsDependencies = {
getConfigDir: () => state.configDir,
getUseV2: () => state.useV2,
getUsePluginEtc: () => state.usePluginEtc,
getDeviceAndVaultName: () => state.deviceAndVaultName,
};
return createCustomisationSyncPathOperations(dependencies);
}
describe("Customisation Sync path operations", () => {
it("reads category and target settings through live getters", () => {
const state: PathState = {
configDir: ".obsidian",
useV2: true,
usePluginEtc: true,
deviceAndVaultName: "device-a",
};
const operations = createOperations(state);
const extraPluginFile = ".obsidian/plugins/example/settings.json";
expect(operations.getFileCategory(extraPluginFile)).toBe("PLUGIN_ETC");
expect(operations.isTargetPath(extraPluginFile)).toBe(true);
state.useV2 = false;
expect(operations.getFileCategory(extraPluginFile)).toBe("");
expect(operations.isTargetPath(extraPluginFile)).toBe(false);
state.useV2 = true;
state.usePluginEtc = false;
expect(operations.getFileCategory(extraPluginFile)).toBe("");
state.configDir = ".config";
expect(operations.isTargetPath(extraPluginFile)).toBe(false);
expect(operations.isTargetPath(".config/plugins/example/settings.json")).toBe(false);
});
it("derives V1, V2, and device-prefix paths from the current term and settings", () => {
const state: PathState = {
configDir: ".obsidian",
useV2: true,
usePluginEtc: true,
deviceAndVaultName: "device-a",
};
const operations = createOperations(state);
const path = ".obsidian/plugins/example/main.js";
expect(operations.filenameToUnifiedKey(path)).toBe("ix:device-a/PLUGIN_MAIN/example.md");
expect(operations.filenameWithUnifiedKey(path)).toBe("ix:device-a/PLUGIN_MAIN/example%main.js");
expect(operations.unifiedKeyPrefixOfTerminal()).toBe("ix:device-a/");
state.deviceAndVaultName = "device-b";
expect(operations.filenameToUnifiedKey(path)).toBe("ix:device-b/PLUGIN_MAIN/example.md");
expect(operations.filenameWithUnifiedKey(path)).toBe("ix:device-b/PLUGIN_MAIN/example%main.js");
expect(operations.unifiedKeyPrefixOfTerminal()).toBe("ix:device-b/");
});
it("keeps the existing override fallback semantics", () => {
const state: PathState = {
configDir: ".obsidian",
useV2: true,
usePluginEtc: true,
deviceAndVaultName: "device-a",
};
const operations = createOperations(state);
const path = ".obsidian/app.json";
expect(operations.filenameToUnifiedKey(path, "device-b")).toBe("ix:device-b/CONFIG/app.json.md");
expect(operations.filenameWithUnifiedKey(path, "device-b")).toBe("ix:device-b/CONFIG/app.json%app.json");
expect(operations.unifiedKeyPrefixOfTerminal("device-b")).toBe("ix:device-b/");
expect(operations.filenameToUnifiedKey(path, "")).toBe("ix:device-a/CONFIG/app.json.md");
expect(operations.filenameWithUnifiedKey(path, "")).toBe("ix:device-a/CONFIG/app.json%app.json");
expect(operations.unifiedKeyPrefixOfTerminal("")).toBe("ix:device-a/");
});
});
@@ -0,0 +1,130 @@
import { ICXHeader } from "@/common/types.ts";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
export type CustomisationSyncFileCategory =
| "CONFIG"
| "THEME"
| "SNIPPET"
| "PLUGIN_MAIN"
| "PLUGIN_ETC"
| "PLUGIN_DATA"
| "";
export type CustomisationSyncPathOptions = {
configDir: string;
useV2: boolean;
usePluginEtc: boolean;
};
export function getCustomisationSyncCategoryFolder(category: string, configDir: string = ""): string {
switch (category) {
case "CONFIG":
return `${configDir}/`;
case "THEME":
return `${configDir}/themes/`;
case "SNIPPET":
return `${configDir}/snippets/`;
case "PLUGIN_MAIN":
case "PLUGIN_DATA":
case "PLUGIN_ETC":
return `${configDir}/plugins/`;
default:
return "";
}
}
export function getCustomisationSyncFileCategory(
filePath: string,
options: CustomisationSyncPathOptions
): CustomisationSyncFileCategory {
if (filePath.split("/").length == 2 && filePath.endsWith(".json")) return "CONFIG";
if (filePath.split("/").length == 4 && filePath.startsWith(`${options.configDir}/themes/`)) return "THEME";
if (filePath.startsWith(`${options.configDir}/snippets/`) && filePath.endsWith(".css")) return "SNIPPET";
if (filePath.startsWith(`${options.configDir}/plugins/`)) {
if (filePath.endsWith("/styles.css") || filePath.endsWith("/manifest.json") || filePath.endsWith("/main.js")) {
return "PLUGIN_MAIN";
}
if (filePath.endsWith("/data.json")) {
return "PLUGIN_DATA";
}
return options.useV2 && options.usePluginEtc ? "PLUGIN_ETC" : "";
}
return "";
}
export function isCustomisationSyncTargetPath(filePath: string, options: CustomisationSyncPathOptions): boolean {
if (!filePath.startsWith(options.configDir)) return false;
return getCustomisationSyncFileCategory(filePath, options) != "";
}
export function getCustomisationSyncSettingKey(
filePath: string,
options: CustomisationSyncPathOptions
): string | undefined {
if (!isCustomisationSyncTargetPath(filePath, options)) return undefined;
const category = getCustomisationSyncFileCategory(filePath, options);
const name =
category == "CONFIG" || category == "SNIPPET"
? filePath.split("/").slice(-1)[0]
: filePath.split("/").slice(-2)[0];
return name ? `${category}/${name}` : undefined;
}
export function getCustomisationSyncSettingKeyFromDocumentPath(documentPath: FilePathWithPrefix): string | undefined {
const [, category, ...rest] = stripAllPrefixes(documentPath).split("/");
if (!category || rest.length == 0) return undefined;
if (!["CONFIG", "THEME", "SNIPPET", "PLUGIN_MAIN", "PLUGIN_ETC", "PLUGIN_DATA"].includes(category)) {
return undefined;
}
const encodedName = category == "CONFIG" || category == "SNIPPET" ? rest.join("/") : rest[0];
const name = encodedName.split("%")[0].replace(/\.md$/, "");
return name ? `${category}/${name}` : undefined;
}
export function createCustomisationSyncV1DocumentPath(
filePath: string,
device: string,
options: CustomisationSyncPathOptions
): FilePathWithPrefix {
const category = getCustomisationSyncFileCategory(filePath, options);
const name =
category == "CONFIG" || category == "SNIPPET"
? filePath.split("/").slice(-1)[0]
: category == "PLUGIN_ETC"
? filePath.split("/").slice(-2).join("/")
: filePath.split("/").slice(-2)[0];
return `${ICXHeader}${device}/${category}/${name}.md` as FilePathWithPrefix;
}
export function createCustomisationSyncV2DocumentPath(
filePath: string,
device: string,
options: CustomisationSyncPathOptions
): FilePathWithPrefix {
const category = getCustomisationSyncFileCategory(filePath, options);
const name =
category == "CONFIG" || category == "SNIPPET"
? filePath.split("/").slice(-1)[0]
: filePath.split("/").slice(-2)[0];
const baseName = category == "CONFIG" || category == "SNIPPET" ? name : filePath.split("/").slice(3).join("/");
return `${ICXHeader}${device}/${category}/${name}%${baseName}` as FilePathWithPrefix;
}
export function createCustomisationSyncDevicePrefix(device: string): FilePathWithPrefix {
return `${ICXHeader}${device}/` as FilePathWithPrefix;
}
export function parseCustomisationSyncV2DocumentPath(unifiedPath: FilePathWithPrefix): {
category: string;
device: string;
key: string;
filename: string;
pathV1: FilePathWithPrefix;
} {
const [device, category, ...rest] = stripAllPrefixes(unifiedPath).split("/");
const relativePath = rest.join("/");
const [key, filename] = relativePath.split("%");
const pathV1 = (unifiedPath.split("%")[0] + ".md") as FilePathWithPrefix;
return { device, category, key, filename, pathV1 };
}
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createCustomisationSyncDevicePrefix,
createCustomisationSyncV1DocumentPath,
createCustomisationSyncV2DocumentPath,
getCustomisationSyncCategoryFolder,
getCustomisationSyncFileCategory,
isCustomisationSyncTargetPath,
getCustomisationSyncSettingKey,
getCustomisationSyncSettingKeyFromDocumentPath,
parseCustomisationSyncV2DocumentPath,
type CustomisationSyncPathOptions,
} from "./customisationSyncPaths.ts";
const currentOptions: CustomisationSyncPathOptions = {
configDir: ".obsidian",
useV2: true,
usePluginEtc: true,
};
describe("compatibility: Customisation Sync path operations", () => {
it.each([
["CONFIG", ".obsidian/"],
["THEME", ".obsidian/themes/"],
["SNIPPET", ".obsidian/snippets/"],
["PLUGIN_MAIN", ".obsidian/plugins/"],
["PLUGIN_DATA", ".obsidian/plugins/"],
["PLUGIN_ETC", ".obsidian/plugins/"],
["UNKNOWN", ""],
])("maps category %s to folder %s", (category, expected) => {
expect(getCustomisationSyncCategoryFolder(category, ".obsidian")).toBe(expected);
});
it.each([
[".obsidian/app.json", "CONFIG"],
[".obsidian/themes/Minimal/theme.css", "THEME"],
[".obsidian/snippets/example.css", "SNIPPET"],
[".obsidian/plugins/example/manifest.json", "PLUGIN_MAIN"],
[".obsidian/plugins/example/data.json", "PLUGIN_DATA"],
[".obsidian/plugins/example/extra.json", "PLUGIN_ETC"],
] as const)("classifies the maintained path %s as %s", (path, expected) => {
expect(getCustomisationSyncFileCategory(path, currentOptions)).toBe(expected);
});
it("preserves the exact depth and case-sensitive category rules", () => {
expect(getCustomisationSyncFileCategory(".obsidian/themes/Minimal/assets/theme.css", currentOptions)).toBe("");
expect(getCustomisationSyncFileCategory(".obsidian/snippets/example.CSS", currentOptions)).toBe("");
expect(getCustomisationSyncFileCategory(".Obsidian/plugins/example/main.js", currentOptions)).toBe("");
});
it("requires both V2 and plug-in-extra support for other plug-in files", () => {
const path = ".obsidian/plugins/example/extra.json";
expect(getCustomisationSyncFileCategory(path, { ...currentOptions, useV2: false })).toBe("");
expect(getCustomisationSyncFileCategory(path, { ...currentOptions, usePluginEtc: false })).toBe("");
});
it("keeps category classification separate from configuration-directory targeting", () => {
expect(getCustomisationSyncFileCategory("notes/example.json", currentOptions)).toBe("CONFIG");
expect(isCustomisationSyncTargetPath("notes/example.json", currentOptions)).toBe(false);
expect(isCustomisationSyncTargetPath(".obsidian/app.json", currentOptions)).toBe(true);
});
it.each([
[".obsidian/app.json", "CONFIG/app.json"],
[".obsidian/themes/Minimal/theme.css", "THEME/Minimal"],
[".obsidian/snippets/example.css", "SNIPPET/example.css"],
[".obsidian/plugins/example/main.js", "PLUGIN_MAIN/example"],
[".obsidian/plugins/example/data.json", "PLUGIN_DATA/example"],
[".obsidian/plugins/example/extra.json", "PLUGIN_ETC/example"],
] as const)("maps the local path %s to setting key %s", (path, expected) => {
expect(getCustomisationSyncSettingKey(path, currentOptions)).toBe(expected);
});
it.each([
["ix:device-a/CONFIG/app.json.md", "CONFIG/app.json"],
["ix:device-a/CONFIG/app.json%app.json", "CONFIG/app.json"],
["ix:device-a/THEME/Minimal.md", "THEME/Minimal"],
["ix:device-a/PLUGIN_DATA/example%data.json", "PLUGIN_DATA/example"],
["ix:device-a/PLUGIN_ETC/example/extra.json.md", "PLUGIN_ETC/example"],
] as const)("maps the persisted path %s to setting key %s", (path, expected) => {
expect(getCustomisationSyncSettingKeyFromDocumentPath(path as FilePathWithPrefix)).toBe(expected);
});
it.each([
[".obsidian/app.json", "ix:device-a/CONFIG/app.json.md"],
[".obsidian/themes/Minimal/theme.css", "ix:device-a/THEME/Minimal.md"],
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example.md"],
[".obsidian/plugins/example/extra.json", "ix:device-a/PLUGIN_ETC/example/extra.json.md"],
] as const)("creates the persisted V1 path for %s", (path, expected) => {
expect(createCustomisationSyncV1DocumentPath(path, "device-a", currentOptions)).toBe(expected);
});
it.each([
[".obsidian/app.json", "ix:device-a/CONFIG/app.json%app.json"],
[".obsidian/themes/Minimal/theme.css", "ix:device-a/THEME/Minimal%theme.css"],
[".obsidian/plugins/example/main.js", "ix:device-a/PLUGIN_MAIN/example%main.js"],
[".obsidian/plugins/example/extra.json", "ix:device-a/PLUGIN_ETC/example%extra.json"],
] as const)("creates the persisted V2 path for %s", (path, expected) => {
expect(createCustomisationSyncV2DocumentPath(path, "device-a", currentOptions)).toBe(expected);
});
it("creates and parses device-scoped V2 paths", () => {
expect(createCustomisationSyncDevicePrefix("device-a")).toBe("ix:device-a/");
expect(
parseCustomisationSyncV2DocumentPath("ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix)
).toEqual({
device: "device-a",
category: "PLUGIN_MAIN",
key: "example",
filename: "main.js",
pathV1: "ix:device-a/PLUGIN_MAIN/example.md",
});
});
});
@@ -0,0 +1,208 @@
import type {
FilePath,
FilePathWithPrefix,
LoadedEntry,
LOG_LEVEL,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import {
createSavingEntryFromLoadedEntry,
fireAndForget,
getDocData,
getDocDataAsArray,
isLoadedEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { arrayBufferToBase64, readString } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { base64ToString } from "octagonal-wheels/binary/base64";
import type { PluginDataEx, PluginDataExFile } from "./customisationSyncCodec.ts";
import { getCustomisationSyncCategoryFolder, parseCustomisationSyncV2DocumentPath } from "./customisationSyncPaths.ts";
import type { LoadedEntryPluginDataExFile, PluginDataExDisplay } from "./customisationSyncView.ts";
type CustomisationSyncLogDependency = {
log: LogFunction;
};
type CustomisationSyncStorageMethods<Method extends keyof StorageAccess> = {
storageAccess: Pick<StorageAccess, Method>;
};
type CustomisationSyncDatabaseMethods<Method extends keyof LiveSyncLocalDB> = {
getLocalDatabase(): Pick<LiveSyncLocalDB, Method>;
};
type CustomisationSyncPathMethods<Method extends keyof IPathService> = {
path: Pick<IPathService, Method>;
};
export type CustomisationSyncFileReaderDependencies = CustomisationSyncStorageMethods<
"readHiddenFileBinary" | "statHidden"
> &
CustomisationSyncLogDependency;
export type CustomisationSyncDisplayLoaderDependencies = CustomisationSyncDatabaseMethods<"getDBEntry" | "putDBEntry"> &
CustomisationSyncPathMethods<"getPath"> &
CustomisationSyncLogDependency;
export type CustomisationSyncV2EntryLoaderDependencies = CustomisationSyncDatabaseMethods<"getDBEntry"> &
CustomisationSyncLogDependency;
export type CustomisationSyncReadCodec = {
deserialize<T>(source: string[], defaultValue: T): T;
serialize(data: PluginDataEx): string;
};
export type DecodedCustomisationSyncV2File = {
confKey: string;
file: LoadedEntryPluginDataExFile;
isManifest: boolean;
};
function log(dependencies: CustomisationSyncLogDependency, message: unknown, level?: LOG_LEVEL, key?: string): void {
dependencies.log(message, level, key);
}
export async function readCustomisationFile(
dependencies: CustomisationSyncFileReaderDependencies,
path: FilePath,
configDir: string
): Promise<false | PluginDataExFile> {
const stat = await dependencies.storageAccess.statHidden(path);
let version: string | undefined;
let displayName: string | undefined;
if (!stat) {
return false;
}
const contentBin = await dependencies.storageAccess.readHiddenFileBinary(path);
let content: string[];
try {
content = await arrayBufferToBase64(contentBin);
if (path.toLowerCase().endsWith("/manifest.json")) {
const manifestSource = readString(new Uint8Array(contentBin));
try {
const manifest: unknown = JSON.parse(manifestSource);
if (typeof manifest === "object" && manifest !== null) {
if ("version" in manifest) {
version = String(manifest.version);
}
if ("name" in manifest) {
displayName = String(manifest.name);
}
}
} catch (error) {
log(
dependencies,
`Configuration sync data: ${path} looks like manifest, but could not read the version`,
LOG_LEVEL_INFO
);
log(dependencies, error, LOG_LEVEL_VERBOSE);
}
}
} catch (error) {
log(dependencies, `The file ${path} could not be encoded`);
log(dependencies, error, LOG_LEVEL_VERBOSE);
return false;
}
return {
// Compatibility: target validation belongs to the caller. The legacy
// reader derives this name positionally without checking the prefix.
filename: path.substring(configDir.length + 1),
data: content,
mtime: stat.mtime,
size: stat.size,
version,
displayName,
};
}
export async function loadCustomisationDisplayData(
dependencies: CustomisationSyncDisplayLoaderDependencies,
path: FilePathWithPrefix,
codec: CustomisationSyncReadCodec
): Promise<PluginDataExDisplay | false> {
const loaded = await dependencies.getLocalDatabase().getDBEntry(path, undefined, false, false);
if (!loaded) {
return false;
}
const data = codec.deserialize(getDocDataAsArray(loaded.data), {}) as PluginDataEx;
const displayFiles: PluginDataExFile[] = [];
let missingHash = false;
for (const file of data.files) {
const displayFile = { ...file, data: [] as string[] };
if (!file.hash) {
// Compatibility question: the inherited implementation clears the
// display copy before calculating this temporary hash, so callers
// see digestHash([]) until the asynchronously repaired document is
// loaded again. The serialiser still writes the real content hash.
const temporaryHashSource = getDocDataAsArray(displayFile.data);
file.hash = digestHash(temporaryHashSource);
missingHash = true;
}
displayFile.data = [file.hash];
displayFiles.push(displayFile);
}
if (missingHash) {
log(dependencies, `Digest created for ${path} to improve checking`, LOG_LEVEL_VERBOSE);
loaded.data = codec.serialize(data);
// Compatibility: catalogue loading does not wait for the repair write.
fireAndForget(() => dependencies.getLocalDatabase().putDBEntry(createSavingEntryFromLoadedEntry(loaded)));
}
return {
...data,
documentPath: dependencies.path.getPath(loaded),
files: displayFiles,
} satisfies PluginDataExDisplay;
}
export async function loadCustomisationV2Entry(
dependencies: CustomisationSyncV2EntryLoaderDependencies,
path: FilePathWithPrefix
): Promise<LoadedEntry | false> {
const loaded = await dependencies.getLocalDatabase().getDBEntry(path);
if (!loaded) {
log(dependencies, `The file ${path} is not found`, LOG_LEVEL_VERBOSE);
return false;
}
if (!isLoadedEntry(loaded)) {
log(dependencies, `The file ${path} is not a note`, LOG_LEVEL_VERBOSE);
return false;
}
return loaded;
}
export function decodeCustomisationSyncV2File(
path: FilePathWithPrefix,
loaded: LoadedEntry,
dummyEnd: string
): DecodedCustomisationSyncV2File {
const { category, key, filename, device } = parseCustomisationSyncV2DocumentPath(path);
const categoryFolder = getCustomisationSyncCategoryFolder(category, device);
const confKey = `${categoryFolder}${key}`;
const relativeFilename =
`${getCustomisationSyncCategoryFolder(category, "")}${category == "CONFIG" || category == "SNIPPET" ? "" : key + "/"}${filename}`.substring(
1
);
const source = getDocData(loaded.data);
const dataStart = source.indexOf(dummyEnd);
// Compatibility question: a missing marker is not rejected. substring()
// starts at dummyEnd.length - 1, preserving the old best-effort decode.
const encodedData = source.substring(dataStart + dummyEnd.length);
const file: LoadedEntryPluginDataExFile = {
...loaded,
hash: "",
data: [base64ToString(encodedData)],
filename: relativeFilename,
displayName: filename,
};
return {
confKey,
file,
isManifest: filename == "manifest.json",
};
}
@@ -0,0 +1,222 @@
import { describe, expect, it, vi } from "vitest";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_VERBOSE,
type FilePath,
type FilePathWithPrefix,
type LoadedEntry,
type UXStat,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import {
decodeCustomisationSyncV2File,
loadCustomisationDisplayData,
loadCustomisationV2Entry,
readCustomisationFile,
} from "./customisationSyncReadOperations.ts";
const configDir = ".obsidian";
const filePath = ".obsidian/plugins/example/manifest.json" as FilePath;
const documentPath = "ix:device-a/PLUGIN_MAIN/example.md" as FilePathWithPrefix;
const stat = { ctime: 10, mtime: 20, size: 42, type: "file" } as UXStat;
const codec = createCustomisationSyncCodec({
digestHash,
parseYaml: () => undefined,
});
function createDependencies() {
const localDatabase = {
getDBEntry: vi.fn(),
putDBEntry: vi.fn(async (_entry: unknown) => ({ ok: true, id: "id", rev: "1-a" })),
};
const storageAccess = {
statHidden: vi.fn(async () => stat as UXStat | null),
readHiddenFileBinary: vi.fn(),
};
const log = vi.fn();
const dependencies = {
getLocalDatabase: () => localDatabase as never,
storageAccess: storageAccess as never,
path: {
getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path),
} as never,
log,
};
return { dependencies, localDatabase, log, storageAccess };
}
function loadedEntry(path: FilePathWithPrefix, data: string): LoadedEntry {
return {
_id: "entry-id",
_rev: "1-a",
path,
type: "plain",
datatype: "plain",
data,
ctime: 10,
mtime: 20,
size: data.length,
children: [],
eden: {},
} as unknown as LoadedEntry;
}
function pluginData(hash?: string): PluginDataEx {
return {
category: "PLUGIN_MAIN",
name: "example",
term: "device-a",
mtime: 20,
files: [
{
filename: "plugins/example/main.js",
data: ["payload"],
mtime: 20,
size: 7,
hash,
},
],
};
}
describe("Customisation Sync read operations", () => {
it("does not read content when a local file is missing", async () => {
const { dependencies, storageAccess } = createDependencies();
storageAccess.statHidden.mockResolvedValue(null);
await expect(readCustomisationFile(dependencies, filePath, configDir)).resolves.toBe(false);
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
});
it("propagates a storage read failure without converting it to an encoding failure", async () => {
const { dependencies, log, storageAccess } = createDependencies();
const error = new Error("read failed");
storageAccess.readHiddenFileBinary.mockRejectedValue(error);
await expect(readCustomisationFile(dependencies, filePath, configDir)).rejects.toBe(error);
expect(log).not.toHaveBeenCalled();
});
it("encodes a manifest and extracts its display metadata", async () => {
const { dependencies, storageAccess } = createDependencies();
const source = JSON.stringify({ name: "Example plug-in", version: "1.2.3" });
storageAccess.readHiddenFileBinary.mockResolvedValue(new TextEncoder().encode(source).buffer);
await expect(readCustomisationFile(dependencies, filePath, configDir)).resolves.toEqual({
filename: "plugins/example/manifest.json",
data: [btoa(source)],
mtime: 20,
size: 42,
version: "1.2.3",
displayName: "Example plug-in",
});
});
it("keeps an unreadable manifest as file data and reports only the metadata failure", async () => {
const { dependencies, log, storageAccess } = createDependencies();
const errorSource = "{invalid";
storageAccess.readHiddenFileBinary.mockResolvedValue(new TextEncoder().encode(errorSource).buffer);
await expect(readCustomisationFile(dependencies, filePath, configDir)).resolves.toMatchObject({
filename: "plugins/example/manifest.json",
data: [btoa(errorSource)],
version: undefined,
displayName: undefined,
});
expect(log).toHaveBeenNthCalledWith(
1,
`Configuration sync data: ${filePath} looks like manifest, but could not read the version`,
LOG_LEVEL_INFO,
undefined
);
expect(log).toHaveBeenNthCalledWith(2, expect.any(SyntaxError), LOG_LEVEL_VERBOSE, undefined);
});
it("loads V1 display data without retaining file content", async () => {
const { dependencies, localDatabase } = createDependencies();
const data = pluginData("known-hash");
localDatabase.getDBEntry.mockResolvedValue(loadedEntry(documentPath, JSON.stringify(data)));
await expect(loadCustomisationDisplayData(dependencies, documentPath, codec)).resolves.toEqual({
...data,
documentPath,
files: [{ ...data.files[0], data: ["known-hash"] }],
});
expect(localDatabase.getDBEntry).toHaveBeenCalledWith(documentPath, undefined, false, false);
expect(localDatabase.putDBEntry).not.toHaveBeenCalled();
});
it("preserves the inherited transient empty-data hash while repairing a V1 document", async () => {
const { dependencies, localDatabase, log } = createDependencies();
const data = pluginData();
localDatabase.getDBEntry.mockResolvedValue(loadedEntry(documentPath, JSON.stringify(data)));
const result = await loadCustomisationDisplayData(dependencies, documentPath, codec);
expect(result).toMatchObject({ files: [{ data: [digestHash([])] }] });
expect(localDatabase.putDBEntry).toHaveBeenCalledOnce();
const saving = localDatabase.putDBEntry.mock.calls[0][0] as { data: Blob };
expect(await saving.data.text()).toContain(digestHash(["payload"]));
expect(log).toHaveBeenCalledWith(
`Digest created for ${documentPath} to improve checking`,
LOG_LEVEL_VERBOSE,
undefined
);
});
it("returns false when a V1 document is absent", async () => {
const { dependencies, localDatabase } = createDependencies();
localDatabase.getDBEntry.mockResolvedValue(false);
await expect(loadCustomisationDisplayData(dependencies, documentPath, codec)).resolves.toBe(false);
});
it("distinguishes an absent V2 entry from a non-note database entry", async () => {
const { dependencies, localDatabase, log } = createDependencies();
const path = "ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix;
localDatabase.getDBEntry.mockResolvedValueOnce(false);
await expect(loadCustomisationV2Entry(dependencies, path)).resolves.toBe(false);
expect(log).toHaveBeenLastCalledWith(`The file ${path} is not found`, LOG_LEVEL_VERBOSE, undefined);
localDatabase.getDBEntry.mockResolvedValueOnce({ path, type: "leaf" });
await expect(loadCustomisationV2Entry(dependencies, path)).resolves.toBe(false);
expect(log).toHaveBeenLastCalledWith(`The file ${path} is not a note`, LOG_LEVEL_VERBOSE, undefined);
});
it("returns a loaded V2 entry after the exact single-argument database lookup", async () => {
const { dependencies, localDatabase } = createDependencies();
const path = "ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix;
const loaded = loadedEntry(path, "data");
localDatabase.getDBEntry.mockResolvedValue(loaded);
await expect(loadCustomisationV2Entry(dependencies, path)).resolves.toBe(loaded);
expect(localDatabase.getDBEntry).toHaveBeenCalledWith(path);
});
it("decodes a V2 payload into its relative Customisation Sync filename", () => {
const path = "ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix;
const loaded = loadedEntry(path, `${codec.dummyHead}${codec.dummyEnd}${btoa("console.log('example');")}`);
expect(decodeCustomisationSyncV2File(path, loaded, codec.dummyEnd)).toEqual({
confKey: "device-a/plugins/example",
isManifest: false,
file: {
...loaded,
filename: "plugins/example/main.js",
displayName: "main.js",
hash: "",
data: ["console.log('example');"],
},
});
});
it("preserves the inherited best-effort offset when a V2 marker is missing", () => {
const path = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const loaded = loadedEntry(path, `00${btoa("hello")}`);
expect(decodeCustomisationSyncV2File(path, loaded, "END").file.data).toEqual(["hello"]);
});
});
@@ -0,0 +1,17 @@
const MAX_RECENT_CUSTOMISATION_EVENTS = 100;
/** Keeps the bounded newest-first raw-event keys used by Customisation Sync. */
export class CustomisationSyncRecentEventDeduplicator {
private keys: string[] = [];
/**
* Records a key when it is new and returns whether the caller should act.
* Native Array#includes is intentional: the old `.contains` extension is
* not available in every runtime where the feature is exercised.
*/
admit(key: string): boolean {
if (this.keys.includes(key)) return false;
this.keys = [key, ...this.keys].slice(0, MAX_RECENT_CUSTOMISATION_EVENTS);
return true;
}
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { CustomisationSyncRecentEventDeduplicator } from "./customisationSyncRecentEventDeduplicator.ts";
describe("Customisation Sync recent raw-event keys", () => {
it("admits a key once and keeps newer keys first", () => {
const history = new CustomisationSyncRecentEventDeduplicator();
expect(history.admit("old")).toBe(true);
expect(history.admit("new")).toBe(true);
expect(history.admit("old")).toBe(false);
});
it("evicts the oldest key when the newest-first history exceeds 100 entries", () => {
const history = new CustomisationSyncRecentEventDeduplicator();
for (let index = 0; index < 101; index++) {
expect(history.admit(`key-${index}`)).toBe(true);
}
expect(history.admit("key-0")).toBe(true);
expect(history.admit("key-100")).toBe(false);
});
});
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const uiSources = [
["PluginDialogModal", readFileSync(new URL("./PluginDialogModal.ts", import.meta.url), "utf8")],
["PluginPane", readFileSync(new URL("./PluginPane.svelte", import.meta.url), "utf8")],
["PluginCombo", readFileSync(new URL("./PluginCombo.svelte", import.meta.url), "utf8")],
[
"PaneCustomisationSync",
readFileSync(
new URL("../../modules/features/SettingDialogue/PaneCustomisationSync.ts", import.meta.url),
"utf8"
),
],
[
"PaneHatch",
readFileSync(new URL("../../modules/features/SettingDialogue/PaneHatch.ts", import.meta.url), "utf8"),
],
] as const;
const customisationSyncSource = readFileSync(new URL("./customisationSyncContext.ts", import.meta.url), "utf8");
const settingTabSource = readFileSync(
new URL("../../modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts", import.meta.url),
"utf8"
);
const settingModuleSource = readFileSync(
new URL("../../modules/features/ModuleObsidianSettingTab.ts", import.meta.url),
"utf8"
);
describe("optional-file synchronisation UI dependency boundary", () => {
it.each(uiSources)("keeps %s independent from concrete add-ons and the application core", (_name, source) => {
expect(source).not.toMatch(/from ["'][^"']*Cmd(?:Config|HiddenFile)Sync(?:\.ts)?["']/);
expect(source).not.toMatch(/from ["'][^"']*main(?:\.ts)?["']/);
expect(source).not.toContain("getAddOn(");
expect(source).not.toContain("getAddOn<");
});
it("keeps the Customisation Sync runtime independent from its Obsidian dialogue", () => {
expect(customisationSyncSource).not.toMatch(/from ["'][^"']*PluginDialogModal(?:\.ts)?["']/);
});
it("keeps the settings presentation out of the runtime cycle and off the compatibility lookup", () => {
expect(settingTabSource).not.toMatch(/^import(?!\s+type\b)[^;]*from ["'][^"']*main(?:\.ts)?["'];/mu);
expect(settingModuleSource).not.toContain("getAddOn(");
expect(settingModuleSource).not.toContain("getAddOn<");
});
});
@@ -0,0 +1,118 @@
import type { PluginManifest } from "@/deps.ts";
import type {
EntryDoc,
FilePathWithPrefix,
FilePath,
LoadedEntry,
PluginSyncSettingEntry,
SYNC_MODE,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type PouchDB from "pouchdb-core";
import type { Readable } from "svelte/store";
import type { PluginDataExFile } from "./customisationSyncCodec.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import type { PluginDataExDisplayV2 } from "./customisationSyncModel.ts";
export type LoadedEntryPluginDataExFile = LoadedEntry & PluginDataExFile;
export type { CustomisationSyncFileCategory } from "./customisationSyncPaths.ts";
export interface IPluginDataExDisplay {
documentPath: FilePathWithPrefix;
category: string;
name: string;
term: string;
displayName?: string;
files: (LoadedEntryPluginDataExFile | PluginDataExFile)[];
version?: string;
mtime: number;
}
export type PluginDataExDisplay = {
documentPath: FilePathWithPrefix;
category: string;
name: string;
term: string;
displayName?: string;
files: PluginDataExFile[];
version?: string;
mtime: number;
};
/**
* Semantic callbacks registered by the optional-file composition feature.
*
* The context owns the implementations, while the optional-file composition
* adapts these operations to Commonlib's aggregation contracts. Consumers
* receive only callable operations, not the context or its private state.
*/
export interface CustomisationSyncServiceHandlers {
readonly processOptionalFileEvent: (path: FilePath) => Promise<boolean>;
readonly processVirtualDocument: (docs: PouchDB.Core.ExistingDocument<EntryDoc>) => Promise<boolean>;
readonly onRealiseSetting: () => Promise<boolean>;
readonly onResuming: () => Promise<boolean>;
readonly onBeforeReplicate: (showMessage: boolean) => Promise<boolean>;
readonly onDatabaseInitialised: (showNotice: boolean) => Promise<boolean>;
readonly suspendExtraSync: () => Promise<boolean>;
readonly enableOptionalFeature: (mode: OptionalSyncFeatureMode) => Promise<boolean>;
}
/**
* Explicit internal operations used by maintained real-Obsidian contract
* tests. This is deliberately narrower than the concrete context and does
* not expose reactive stores, queues, or host dependencies.
*/
export interface CustomisationSyncTestingView {
readonly configDir: string;
scanInternalFiles(): Promise<FilePath[]>;
scanAllConfigFiles(showMessage: boolean): Promise<void>;
storeCustomizationFiles(path: FilePath, termOverride?: string): Promise<unknown>;
deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite?: boolean): Promise<boolean>;
createPluginDataFromV2(unifiedPathV2: FilePathWithPrefix): PluginDataExDisplayV2 | undefined;
createPluginDataExFileV2(
unifiedPathV2: FilePathWithPrefix,
loaded?: LoadedEntry
): Promise<false | LoadedEntryPluginDataExFile>;
applyDataV2(data: PluginDataExDisplayV2, content?: string): Promise<boolean>;
}
/** Stable catalogue and operation surface consumed by the Obsidian dialogue. */
export interface CustomisationSyncDialogView {
readonly catalogue: Readable<IPluginDataExDisplay[]>;
readonly enumerationActive: Readable<boolean>;
readonly migrationProgress: Readable<number>;
readonly manifests: Readable<Map<string, PluginManifest>>;
isEnabled(): boolean;
getDeviceAndVaultName(): string;
getConfiguredModes(): PluginSyncSettingEntry[];
isPluginEtcEnabled(): boolean;
updateConfiguredMode(key: string, mode: SYNC_MODE, files: string[]): void;
getConfiguredTargetFiles(key: string): string[];
updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void>;
reloadPluginList(showMessage: boolean): Promise<void>;
scanAllConfigFiles(showMessage: boolean): Promise<void>;
synchronise(): Promise<void>;
applyData(data: IPluginDataExDisplay): Promise<boolean>;
compareUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
compareEach?: boolean
): Promise<boolean>;
compareFileUsingDisplayData(
dataA: IPluginDataExDisplay,
dataB: IPluginDataExDisplay,
filename: string
): Promise<boolean>;
deleteData(data: IPluginDataExDisplay): Promise<boolean>;
duplicateData(data: IPluginDataExDisplay, deviceName: string): Promise<void>;
askString(title: string, key: string, placeholder: string): Promise<string | false>;
}
/** Narrow control returned by the host-owned dialogue composition feature. */
export interface CustomisationSyncUIControl {
open(): void;
close(): void;
isOpen(): boolean;
}
+197
View File
@@ -0,0 +1,197 @@
import type {
AnyEntry,
FilePath,
FilePathWithPrefix,
InternalFileEntry,
LOG_LEVEL,
ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { shareRunningResult } from "octagonal-wheels/concurrency/lock";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { $msg } from "@/common/translation";
import { ICXHeader } from "@/common/types.ts";
import {
collectOptionalFileSyncFiles,
type OptionalFileSyncFileTreeDependencies,
} from "@/features/optionalFileSyncFileTree.ts";
import type { CatalogueOperations } from "./catalogueOperations.ts";
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
import type { SnapshotOperations } from "./snapshotOperations.ts";
type ScanSettings = Pick<ObsidianLiveSyncSettings, "usePluginSyncV2">;
type ScanDatabase = Pick<LiveSyncLocalDB, "allDocsRaw" | "findEntries">;
type ScanPathOperations = Pick<
CustomisationSyncPathOperations,
"isTargetPath" | "filenameToUnifiedKey" | "filenameWithUnifiedKey" | "unifiedKeyPrefixOfTerminal"
> & {
getPath(entry: AnyEntry): FilePathWithPrefix;
};
type ScanSnapshotOperations = Pick<
SnapshotOperations,
"storeCustomisationFileV2" | "storeCustomizationFiles" | "deleteConfigOnDatabase"
>;
type ScanCatalogueOperations = Pick<CatalogueOperations, "updatePluginList">;
export type ScanOperationsDependencies = OptionalFileSyncFileTreeDependencies & {
getSettings(): ScanSettings;
getLocalDatabase(): ScanDatabase;
path: ScanPathOperations;
log: LogFunction;
getConfigDir(): string;
getDeviceAndVaultName(): string;
ownsLocalFile(path: FilePath): boolean;
ownsLocalDocument(path: FilePathWithPrefix): boolean;
snapshotOperations: ScanSnapshotOperations;
catalogueOperations: ScanCatalogueOperations;
};
/**
* Owns Customisation Sync file enumeration and reconciliation with the local
* database. Snapshot writes and catalogue publication remain explicit ports so
* scans do not depend on the context or its lifecycle.
*/
export class ScanOperations {
constructor(private readonly dependencies: ScanOperationsDependencies) {}
private get localDatabase() {
return this.dependencies.getLocalDatabase();
}
private getPath(entry: AnyEntry): FilePathWithPrefix {
return this.dependencies.path.getPath(entry);
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
async scanInternalFiles(): Promise<FilePath[]> {
const filenames = (
await collectOptionalFileSyncFiles(this.dependencies, this.dependencies.getConfigDir(), {
maxDepth: 2,
onError: (path, error) => {
this._log(`Could not traverse(CustomisationSync):${path}`, LOG_LEVEL_INFO);
this._log(error, LOG_LEVEL_VERBOSE);
},
})
)
.filter((e) => e.startsWith("."))
.filter((e) => !e.startsWith(".trash"));
return filenames as FilePath[];
}
async scanAllConfigFiles(showMessage: boolean): Promise<void> {
await shareRunningResult("scanAllConfigFiles", async () => {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
this._log("Scanning customizing files.", logLevel, "scan-all-config");
const term = this.dependencies.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
return;
}
const filesAll = await this.scanInternalFiles();
if (this.dependencies.getSettings().usePluginSyncV2) {
await this.scanV2ConfigFiles(filesAll, term);
} else {
await this.scanV1ConfigFiles(filesAll, term);
}
});
}
private async scanV2ConfigFiles(filesAll: readonly FilePath[], term: string): Promise<void> {
const filesAllUnified = filesAll
.filter((e) => this.dependencies.path.isTargetPath(e))
.map((e) => [this.dependencies.path.filenameWithUnifiedKey(e, term), e] as [FilePathWithPrefix, FilePath]);
const localFileMap = new Map(filesAllUnified.map((e) => [e[0], e[1]]));
const prefix = this.dependencies.path.unifiedKeyPrefixOfTerminal(term);
const entries = this.localDatabase.findEntries(prefix + "", `${prefix}\u{10ffff}`, {
include_docs: true,
});
const tasks = [] as (() => Promise<void>)[];
const concurrency = 10;
const semaphore = Semaphore(concurrency);
for await (const item of entries) {
if (item.path.indexOf("%") !== -1) {
continue;
}
tasks.push(async () => {
const releaser = await semaphore.acquire();
try {
const unifiedFilenameWithKey = `${item._id}` as FilePathWithPrefix;
const localPath = localFileMap.get(unifiedFilenameWithKey);
if (localPath) {
if (this.dependencies.ownsLocalFile(localPath)) {
await this.dependencies.snapshotOperations.storeCustomisationFileV2(localPath, term);
}
localFileMap.delete(unifiedFilenameWithKey);
} else if (this.dependencies.ownsLocalDocument(this.getPath(item))) {
await this.dependencies.snapshotOperations.deleteConfigOnDatabase(unifiedFilenameWithKey);
}
} catch (ex) {
this._log(`scanAllConfigFiles - Error: ${item._id}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
} finally {
releaser();
}
});
}
await Promise.all(tasks.map((e) => e()));
// Extra files
const taskExtra = [] as (() => Promise<void>)[];
for (const [, filePath] of localFileMap) {
if (!this.dependencies.ownsLocalFile(filePath)) continue;
taskExtra.push(async () => {
const releaser = await semaphore.acquire();
try {
await this.dependencies.snapshotOperations.storeCustomisationFileV2(filePath, term);
} catch (ex) {
this._log(`scanAllConfigFiles - Error: ${filePath}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
} finally {
releaser();
}
});
}
await Promise.all(taskExtra.map((e) => e()));
fireAndForget(() => this.dependencies.catalogueOperations.updatePluginList(false));
}
private async scanV1ConfigFiles(filesAll: readonly FilePath[], term: string): Promise<void> {
const files = filesAll
.filter((e) => this.dependencies.path.isTargetPath(e))
.map((e) => ({ key: this.dependencies.path.filenameToUnifiedKey(e), file: e }));
const virtualPathsOfLocalFiles = [...new Set(files.map((e) => e.key))];
const filesOnDB = (
(
await this.localDatabase.allDocsRaw({
startkey: ICXHeader + "",
endkey: `${ICXHeader}\u{10ffff}`,
include_docs: true,
})
).rows.map((e) => e.doc) as InternalFileEntry[]
).filter((e) => !e.deleted);
let deleteCandidate = filesOnDB.map((e) => this.getPath(e)).filter((e) => e.startsWith(`${ICXHeader}${term}/`));
for (const vp of virtualPathsOfLocalFiles) {
const p = files.find((e) => e.key == vp)?.file;
if (!p) {
this._log(`scanAllConfigFiles - File not found: ${vp}`, LOG_LEVEL_VERBOSE);
continue;
}
if (this.dependencies.ownsLocalFile(p)) {
await this.dependencies.snapshotOperations.storeCustomizationFiles(p);
}
deleteCandidate = deleteCandidate.filter((e) => e != vp);
}
for (const vp of deleteCandidate) {
if (this.dependencies.ownsLocalDocument(vp)) {
await this.dependencies.snapshotOperations.deleteConfigOnDatabase(vp);
}
}
fireAndForget(() => this.dependencies.catalogueOperations.updatePluginList(false));
}
}
@@ -0,0 +1,327 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type FilePath,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const asyncHarness = vi.hoisted(() => ({
fireAndForget: vi.fn((operation: () => unknown) => {
void operation();
}),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
fireAndForget: asyncHarness.fireAndForget,
};
});
import { ScanOperations, type ScanOperationsDependencies } from "./scanOperations.ts";
type ScanEntry = {
_id: FilePathWithPrefix;
path: FilePathWithPrefix;
deleted?: boolean;
};
type FixtureOptions = {
useV2?: boolean;
usePluginSync?: boolean;
term?: string;
files?: FilePath[];
targetFiles?: FilePath[];
databaseEntries?: ScanEntry[];
v1Paths?: Record<string, FilePathWithPrefix>;
v2Paths?: Record<string, FilePathWithPrefix>;
ownsLocalFile?: (path: FilePath) => boolean;
ownsLocalDocument?: (path: FilePathWithPrefix) => boolean;
listFiles?: (path: string) => Promise<{ files: readonly string[]; folders: readonly string[] }>;
};
function asyncEntries<T>(entries: readonly T[]) {
return {
async *[Symbol.asyncIterator]() {
yield* entries;
},
};
}
function createFixture(options: FixtureOptions = {}) {
const files = options.files ?? [];
const term = options.term ?? "device-a";
const databaseEntries = options.databaseEntries ?? [];
const v1Paths = options.v1Paths ?? {};
const v2Paths = options.v2Paths ?? {};
const log = vi.fn();
const allDocsRaw = vi.fn(async () => ({
rows: databaseEntries.map((doc) => ({ id: doc._id, doc })),
}));
const findEntries = vi.fn(() => asyncEntries(databaseEntries));
const storeCustomisationFileV2 = vi.fn(async (_path: FilePath, _term: string) => true);
const storeCustomizationFiles = vi.fn(async (_path: FilePath) => true);
const deleteConfigOnDatabase = vi.fn(async (_path: FilePathWithPrefix) => true);
const updatePluginList = vi.fn(async (_showMessage: boolean) => undefined);
const listFiles = vi.fn(
options.listFiles ??
(async () => ({ files, folders: [] }) as { files: readonly string[]; folders: readonly string[] })
);
const dependencies = {
listFiles,
getSettings: () => ({ usePluginSyncV2: options.useV2 ?? false, usePluginSync: options.usePluginSync ?? true }),
getLocalDatabase: () => ({ allDocsRaw, findEntries }),
path: {
isTargetPath: (path: string) => options.targetFiles?.includes(path as FilePath) ?? true,
filenameToUnifiedKey: (path: string) =>
v1Paths[path] ?? (`ix:${term}/CONFIG/${path.split("/").pop()}.md` as FilePathWithPrefix),
filenameWithUnifiedKey: (path: string) =>
v2Paths[path] ??
(`ix:${term}/CONFIG/${path.split("/").pop()}%${path.split("/").pop()}` as FilePathWithPrefix),
unifiedKeyPrefixOfTerminal: (termOverride?: string) => `ix:${termOverride ?? term}/` as FilePathWithPrefix,
getPath: (entry: ScanEntry) => entry.path,
},
log,
getConfigDir: () => ".obsidian",
getDeviceAndVaultName: () => term,
ownsLocalFile: options.ownsLocalFile ?? (() => true),
ownsLocalDocument: options.ownsLocalDocument ?? (() => true),
snapshotOperations: {
storeCustomisationFileV2,
storeCustomizationFiles,
deleteConfigOnDatabase,
},
catalogueOperations: { updatePluginList },
} as unknown as ScanOperationsDependencies;
return {
operations: new ScanOperations(dependencies),
dependencies,
listFiles,
log,
allDocsRaw,
findEntries,
snapshot: { storeCustomisationFileV2, storeCustomizationFiles, deleteConfigOnDatabase },
catalogue: { updatePluginList },
};
}
describe("ScanOperations", () => {
beforeEach(() => {
asyncHarness.fireAndForget.mockClear();
});
it("filters the bounded file tree and logs traversal errors", async () => {
const traversalError = new Error("cannot read folder");
const listFiles = vi.fn(async (path: string) => {
switch (path) {
case ".obsidian":
return {
files: [".obsidian/app.json", "settings.json", ".trash/root.json"],
folders: [".obsidian/plugins", ".trash", ".obsidian/unreadable"],
};
case ".obsidian/plugins":
return {
files: [".obsidian/plugins/example/data.json"],
folders: [".obsidian/plugins/example"],
};
case ".obsidian/plugins/example":
return {
files: [".obsidian/plugins/example/manifest.json"],
folders: [".obsidian/plugins/example/deeper"],
};
case ".trash":
return { files: [".trash/ignored.json"], folders: [] };
case ".obsidian/unreadable":
throw traversalError;
default:
throw new Error(`unexpected traversal: ${path}`);
}
});
const fixture = createFixture({ listFiles });
await expect(fixture.operations.scanInternalFiles()).resolves.toEqual([
".obsidian/app.json",
".obsidian/plugins/example/data.json",
".obsidian/plugins/example/manifest.json",
]);
expect(listFiles).not.toHaveBeenCalledWith(".obsidian/plugins/example/deeper");
expect(fixture.log).toHaveBeenCalledWith(
"Could not traverse(CustomisationSync):.obsidian/unreadable",
LOG_LEVEL_INFO,
undefined
);
expect(fixture.log).toHaveBeenCalledWith(traversalError, LOG_LEVEL_VERBOSE, undefined);
});
it("stops before traversal when the device term is empty", async () => {
const fixture = createFixture({ term: "", usePluginSync: false, files: [".obsidian/app.json"] as FilePath[] });
await fixture.operations.scanAllConfigFiles(true);
expect(fixture.listFiles).not.toHaveBeenCalled();
expect(fixture.allDocsRaw).not.toHaveBeenCalled();
expect(fixture.findEntries).not.toHaveBeenCalled();
expect(fixture.snapshot.storeCustomizationFiles).not.toHaveBeenCalled();
expect(fixture.snapshot.storeCustomisationFileV2).not.toHaveBeenCalled();
expect(fixture.snapshot.deleteConfigOnDatabase).not.toHaveBeenCalled();
expect(fixture.catalogue.updatePluginList).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalledWith("Scanning customizing files.", LOG_LEVEL_NOTICE, "scan-all-config");
expect(fixture.log).toHaveBeenCalledWith("We have to configure the device name", LOG_LEVEL_NOTICE, undefined);
});
it("dispatches according to the current V1/V2 setting on each scan", async () => {
const path = ".obsidian/app.json" as FilePath;
const fixture = createFixture({ files: [path], useV2: false });
let useV2 = false;
fixture.dependencies.getSettings = () => ({ usePluginSyncV2: useV2 });
await fixture.operations.scanAllConfigFiles(false);
useV2 = true;
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledWith(path);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledTimes(1);
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledWith(path, "device-a");
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledTimes(1);
});
it("routes V1 ownership and deletes only stale owned documents", async () => {
const ownedPath = ".obsidian/app.json" as FilePath;
const unownedPath = ".obsidian/appearance.json" as FilePath;
const ownedDocument = "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix;
const unownedDocument = "ix:device-a/CONFIG/appearance.json.md" as FilePathWithPrefix;
const staleDocument = "ix:device-a/CONFIG/stale.json.md" as FilePathWithPrefix;
const fixture = createFixture({
useV2: false,
usePluginSync: false,
files: [ownedPath, unownedPath],
v1Paths: {
[ownedPath]: ownedDocument,
[unownedPath]: unownedDocument,
},
databaseEntries: [
{ _id: ownedDocument, path: ownedDocument },
{ _id: unownedDocument, path: unownedDocument },
{ _id: staleDocument, path: staleDocument },
],
ownsLocalFile: (path) => path == ownedPath,
});
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledWith(ownedPath);
expect(fixture.snapshot.storeCustomizationFiles).toHaveBeenCalledTimes(1);
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledWith(staleDocument);
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledTimes(1);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledTimes(1);
expect(fixture.allDocsRaw).toHaveBeenCalledWith({
startkey: "ix:",
endkey: "ix:\u{10ffff}",
include_docs: true,
});
});
it("propagates V1 snapshot failures without publishing a final refresh", async () => {
const path = ".obsidian/app.json" as FilePath;
const failure = new Error("V1 write failed");
const fixture = createFixture({ files: [path] });
fixture.snapshot.storeCustomizationFiles.mockRejectedValueOnce(failure);
await expect(fixture.operations.scanAllConfigFiles(false)).rejects.toBe(failure);
expect(fixture.catalogue.updatePluginList).not.toHaveBeenCalled();
});
it("routes V2 ownership, removes matched keys, and deletes stale documents", async () => {
const ownedPath = ".obsidian/app.json" as FilePath;
const unownedPath = ".obsidian/appearance.json" as FilePath;
const extraPath = ".obsidian/plugins/example/main.js" as FilePath;
const ownedDocument = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const unownedDocument = "ix:device-a/CONFIG/appearance.json%appearance.json" as FilePathWithPrefix;
const staleDocument = "ix:device-a/CONFIG/stale.json" as FilePathWithPrefix;
const skippedDocument = "ix:device-a/CONFIG/skipped%app.json" as FilePathWithPrefix;
const owned = new Set([ownedPath, extraPath]);
const fixture = createFixture({
useV2: true,
files: [ownedPath, unownedPath, extraPath],
v2Paths: {
[ownedPath]: ownedDocument,
[unownedPath]: unownedDocument,
[extraPath]: "ix:device-a/PLUGIN_MAIN/example%main.js" as FilePathWithPrefix,
},
databaseEntries: [
{ _id: ownedDocument, path: "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix },
{ _id: unownedDocument, path: "ix:device-a/CONFIG/appearance.json.md" as FilePathWithPrefix },
{ _id: staleDocument, path: staleDocument },
{ _id: skippedDocument, path: skippedDocument },
],
ownsLocalFile: (path) => owned.has(path),
});
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledWith(ownedPath, "device-a");
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledWith(extraPath, "device-a");
expect(fixture.snapshot.storeCustomisationFileV2).toHaveBeenCalledTimes(2);
expect(fixture.snapshot.storeCustomisationFileV2).not.toHaveBeenCalledWith(unownedPath, "device-a");
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledWith(staleDocument);
expect(fixture.snapshot.deleteConfigOnDatabase).toHaveBeenCalledTimes(1);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
expect(fixture.findEntries).toHaveBeenCalledWith("ix:device-a/", "ix:device-a/\u{10ffff}", {
include_docs: true,
});
});
it("catches and logs each V2 entry failure before publishing the refresh", async () => {
const path = ".obsidian/app.json" as FilePath;
const document = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const failure = new Error("V2 write failed");
const fixture = createFixture({
useV2: true,
files: [path],
v2Paths: { [path]: document },
databaseEntries: [{ _id: document, path: "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix }],
});
fixture.snapshot.storeCustomisationFileV2.mockRejectedValueOnce(failure);
await fixture.operations.scanAllConfigFiles(false);
expect(fixture.log).toHaveBeenCalledWith(
`scanAllConfigFiles - Error: ${document}`,
LOG_LEVEL_VERBOSE,
undefined
);
expect(fixture.log).toHaveBeenCalledWith(failure, LOG_LEVEL_VERBOSE, undefined);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
});
it("starts the final catalogue refresh without awaiting it", async () => {
const path = ".obsidian/app.json" as FilePath;
const fixture = createFixture({ files: [path] });
let releaseRefresh!: () => void;
const refresh = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
const refreshStarted = vi.fn();
fixture.catalogue.updatePluginList.mockImplementation(async () => {
refreshStarted();
await refresh;
});
await fixture.operations.scanAllConfigFiles(false);
expect(asyncHarness.fireAndForget).toHaveBeenCalledOnce();
expect(refreshStarted).toHaveBeenCalledOnce();
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false);
releaseRefresh();
await refresh;
});
});
@@ -0,0 +1,82 @@
import type {
FilePath,
FilePathWithPrefix,
LOG_LEVEL,
ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { $msg } from "@/common/translation";
import type { CatalogueOperations } from "./catalogueOperations.ts";
import type { SnapshotPersistence, SnapshotRefresh } from "./snapshotPersistence.ts";
type SnapshotSettings = Pick<ObsidianLiveSyncSettings, "usePluginSyncV2">;
type SnapshotPersistencePort = Pick<
SnapshotPersistence,
"storeCustomisationFileV2" | "storeCustomizationFiles" | "deleteConfigOnDatabase"
>;
type SnapshotCatalogue = Pick<CatalogueOperations, "updatePluginList" | "updatePluginListV2">;
export type SnapshotOperationsDependencies = {
getSettings(): SnapshotSettings;
getDeviceAndVaultName(): string;
log(message: unknown, level?: LOG_LEVEL, key?: string): void;
snapshotPersistence: SnapshotPersistencePort;
catalogueOperations: SnapshotCatalogue;
};
/**
* Adapts host-neutral Customisation Sync snapshot mutations to catalogue
* refreshes. Current-term selection and the inherited refresh timing live in
* this owner so scan, dialogue, and testing callers share one policy.
*/
export class SnapshotOperations {
constructor(private readonly dependencies: SnapshotOperationsDependencies) {}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
isV2Enabled(): boolean {
return this.dependencies.getSettings().usePluginSyncV2;
}
private async applyPersistenceRefreshes(refreshes: readonly SnapshotRefresh[]) {
for (const refresh of refreshes) {
if (refresh.mode == "v2" && refresh.timing == "fire-and-forget") {
fireAndForget(() => this.dependencies.catalogueOperations.updatePluginListV2(false, refresh.path));
} else if (refresh.mode == "v1" && refresh.timing == "await") {
await this.dependencies.catalogueOperations.updatePluginList(false, refresh.path);
}
}
}
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
const persistence = await this.dependencies.snapshotPersistence.storeCustomisationFileV2(path, term, force);
await this.applyPersistenceRefreshes(persistence.refreshes);
return persistence.value;
}
async storeCustomizationFiles(path: FilePath, termOverride?: string) {
const term = termOverride || this.dependencies.getDeviceAndVaultName();
if (term == "") {
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
return;
}
const persistence = this.isV2Enabled()
? await this.dependencies.snapshotPersistence.storeCustomisationFileV2(path, term)
: await this.dependencies.snapshotPersistence.storeCustomizationFiles(path, term);
await this.applyPersistenceRefreshes(persistence.refreshes);
return persistence.value;
}
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false): Promise<boolean> {
const persistence = await this.dependencies.snapshotPersistence.deleteConfigOnDatabase(
prefixedFileName,
forceWrite
);
await this.applyPersistenceRefreshes(persistence.refreshes);
return persistence.value;
}
}
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from "vitest";
const asyncHarness = vi.hoisted(() => ({
fireAndForget: vi.fn((operation: () => unknown) => {
void operation();
}),
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
fireAndForget: asyncHarness.fireAndForget,
};
});
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SnapshotPersistenceResult } from "./snapshotPersistence.ts";
import { SnapshotOperations, type SnapshotOperationsDependencies } from "./snapshotOperations.ts";
const CONFIG_PATH = ".obsidian/app.json" as FilePath;
const V1_PATH = "ix:device-b/CONFIG/app.json.md" as FilePathWithPrefix;
const V2_PATH = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
function createOperations(usePluginSyncV2: boolean) {
const events: string[] = [];
type PersistenceResult = SnapshotPersistenceResult<true>;
const storeCustomisationFileV2 = vi.fn(
async (_path: FilePath, _term: string, _force?: boolean): Promise<PersistenceResult> => ({
value: true,
status: "saved",
refreshes: [],
})
);
const storeCustomizationFiles = vi.fn(
async (_path: FilePath, _term: string): Promise<PersistenceResult> => ({
value: true,
status: "saved",
refreshes: [],
})
);
const deleteConfigOnDatabase = vi.fn(
async (_path: FilePathWithPrefix, _force?: boolean): Promise<PersistenceResult> => ({
value: true,
status: "deleted",
refreshes: [],
})
);
const updatePluginList = vi.fn(async () => {
events.push("refresh-v1");
});
const updatePluginListV2 = vi.fn(async () => {
events.push("refresh-v2");
});
const dependencies: SnapshotOperationsDependencies = {
getSettings: () => ({ usePluginSyncV2 }),
getDeviceAndVaultName: () => "device-a",
log: vi.fn(),
snapshotPersistence: {
storeCustomisationFileV2,
storeCustomizationFiles,
deleteConfigOnDatabase,
},
catalogueOperations: { updatePluginList, updatePluginListV2 },
};
return {
operations: new SnapshotOperations(dependencies),
events,
persistence: { storeCustomisationFileV2, storeCustomizationFiles, deleteConfigOnDatabase },
catalogue: { updatePluginList, updatePluginListV2 },
};
}
describe("Snapshot Operations", () => {
it("selects V1 persistence with an override term and awaits its refresh", async () => {
const fixture = createOperations(false);
fixture.persistence.storeCustomizationFiles.mockImplementation(async (_path: FilePath, term: string) => {
fixture.events.push(`persist:${term}`);
return {
value: true,
status: "saved",
refreshes: [{ mode: "v1", timing: "await", path: V1_PATH }],
};
});
await expect(fixture.operations.storeCustomizationFiles(CONFIG_PATH, "device-b")).resolves.toBe(true);
expect(fixture.persistence.storeCustomizationFiles).toHaveBeenCalledWith(CONFIG_PATH, "device-b");
expect(fixture.events).toEqual(["persist:device-b", "refresh-v1"]);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false, V1_PATH);
});
it("selects V2 persistence with the current term and does not await its refresh", async () => {
const fixture = createOperations(true);
let releaseRefresh!: () => void;
const refresh = new Promise<void>((resolve) => {
releaseRefresh = resolve;
});
fixture.persistence.storeCustomisationFileV2.mockImplementation(async (_path: FilePath, term: string) => {
fixture.events.push(`persist:${term}`);
return {
value: true,
status: "saved",
refreshes: [{ mode: "v2", timing: "fire-and-forget", path: V2_PATH }],
};
});
fixture.catalogue.updatePluginListV2.mockImplementation(async () => {
fixture.events.push("refresh-v2-start");
await refresh;
fixture.events.push("refresh-v2-end");
});
await expect(fixture.operations.storeCustomizationFiles(CONFIG_PATH)).resolves.toBe(true);
expect(fixture.events).toEqual(["persist:device-a", "refresh-v2-start"]);
releaseRefresh();
await refresh;
expect(fixture.events).toEqual(["persist:device-a", "refresh-v2-start", "refresh-v2-end"]);
});
it("returns the persistence result after applying deletion refreshes", async () => {
const fixture = createOperations(false);
fixture.persistence.deleteConfigOnDatabase.mockImplementation(async () => ({
value: true,
status: "deleted",
refreshes: [{ mode: "v1", timing: "await", path: V1_PATH }],
}));
await expect(fixture.operations.deleteConfigOnDatabase(V1_PATH)).resolves.toBe(true);
expect(fixture.persistence.deleteConfigOnDatabase).toHaveBeenCalledWith(V1_PATH, false);
expect(fixture.catalogue.updatePluginList).toHaveBeenCalledWith(false, V1_PATH);
});
});
@@ -0,0 +1,402 @@
import { parseYaml } from "@/deps.ts";
import type {
FilePath,
FilePathWithPrefix,
InternalFileEntry,
LOG_LEVEL,
SavingEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_DEBUG, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createBlob,
createTextBlob,
getDocData,
getDocDataAsArray,
isDocContentSame,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { EVEN } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { arrayBufferToBase64 } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { IPathService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { base64ToArrayBuffer } from "octagonal-wheels/binary/base64";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import { createCustomisationSyncCodec, type PluginDataEx } from "./customisationSyncCodec.ts";
import type { CustomisationSyncPathOperations } from "./customisationSyncPathOperations.ts";
import { readCustomisationFile } from "./customisationSyncReadOperations.ts";
const {
serialize,
deserialize,
dummyHead: DUMMY_HEAD,
dummyEnd: DUMMY_END,
} = createCustomisationSyncCodec({ digestHash, parseYaml });
type SnapshotPersistenceDatabase = Pick<
LiveSyncLocalDB,
"getDBEntryFromMeta" | "getDBEntryMeta" | "putDBEntry" | "putRaw"
>;
type SnapshotPersistenceStorage = Pick<StorageAccess, "readHiddenFileBinary" | "statHidden">;
type SnapshotPersistencePath = Pick<
CustomisationSyncPathOperations,
"getFileCategory" | "filenameToUnifiedKey" | "filenameWithUnifiedKey"
> &
Pick<IPathService, "isMarkedAsSameChanges" | "markChangesAreSame" | "path2id">;
export type SnapshotPersistenceDependencies = {
getLocalDatabase(): SnapshotPersistenceDatabase;
storageAccess: SnapshotPersistenceStorage;
path: SnapshotPersistencePath;
log: LogFunction;
getConfigDir(): string;
};
export type SnapshotRefresh = {
mode: "v1" | "v2";
timing: "await" | "fire-and-forget";
path: FilePathWithPrefix;
};
export type SnapshotPersistenceStatus = "saved" | "skipped" | "missing" | "deleted" | "already-deleted" | "failed";
export type SnapshotPersistenceResult<Value> = {
value: Value;
status: SnapshotPersistenceStatus;
refreshes: readonly SnapshotRefresh[];
};
type DatabaseSaveResult = Awaited<ReturnType<SnapshotPersistenceDatabase["putDBEntry"]>>;
type StoreResultValue = DatabaseSaveResult | true | undefined;
function result<Value>(
value: Value,
status: SnapshotPersistenceStatus,
refreshes: readonly SnapshotRefresh[] = []
): SnapshotPersistenceResult<Value> {
return { value, status, refreshes };
}
/**
* Persists local Customisation Sync snapshots without owning catalogue state,
* lifecycle, replication, or user-interface behaviour.
*/
export class SnapshotPersistence {
private readonly dependencies: SnapshotPersistenceDependencies;
constructor(dependencies: SnapshotPersistenceDependencies) {
this.dependencies = dependencies;
}
private _log(message: unknown, level?: LOG_LEVEL, key?: string) {
this.dependencies.log(message, level, key);
}
private async readFile(path: FilePath) {
return await readCustomisationFile(
{
storageAccess: this.dependencies.storageAccess,
log: this.dependencies.log,
},
path,
this.dependencies.getConfigDir()
);
}
// Compatibility question: the inherited force parameter is not read.
// Preserve it until its intended write-bypass semantics are decided.
async storeCustomisationFileV2(
path: FilePath,
term: string,
force = false
): Promise<SnapshotPersistenceResult<StoreResultValue>> {
void force;
const vf = this.dependencies.path.filenameWithUnifiedKey(path, term);
return await serialized(`plugin-${vf}`, async () => {
const prefixedFileName = vf;
const id = await this.dependencies.path.path2id(prefixedFileName);
const stat = await this.dependencies.storageAccess.statHidden(path);
if (!stat) {
return result(false, "missing");
}
const mtime = stat.mtime;
const content = await this.dependencies.storageAccess.readHiddenFileBinary(path);
const contentBlob = createBlob([DUMMY_HEAD, DUMMY_END, ...(await arrayBufferToBase64(content))]);
// const contentBlob = createBlob(content);
try {
const old = await this.dependencies
.getLocalDatabase()
.getDBEntryMeta(prefixedFileName, undefined, false);
let saveData: SavingEntry;
if (old === false) {
saveData = {
_id: id,
path: prefixedFileName,
data: contentBlob,
mtime,
ctime: mtime,
datatype: "plain",
size: contentBlob.size,
children: [],
deleted: false,
type: "plain",
eden: {},
};
} else {
// Compatibility question: this inherited marker check
// precedes loading the old document and can suppress a
// content comparison. Preserve that event-suppression
// ordering until its scan contract is reviewed.
if (
this.dependencies.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
LOG_LEVEL_DEBUG
);
return result(undefined, "skipped");
}
const docXDoc = await this.dependencies.getLocalDatabase().getDBEntryFromMeta(old, false, false);
if (docXDoc == false) {
throw new LiveSyncError("Could not load the document");
}
const dataSrc = getDocData(docXDoc.data);
const dataStart = dataSrc.indexOf(DUMMY_END);
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
const oldContentArray = base64ToArrayBuffer(oldContent);
if (await isDocContentSame(oldContentArray, content)) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
LOG_LEVEL_VERBOSE
);
this.dependencies.path.markChangesAreSame(prefixedFileName, old.mtime, mtime + 1);
return result(true, "skipped");
}
saveData = {
...old,
data: contentBlob,
mtime,
size: contentBlob.size,
datatype: "plain",
children: [],
deleted: false,
type: "plain",
};
}
const ret = await this.dependencies.getLocalDatabase().putDBEntry(saveData);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
// Compatibility question: the inherited refresh path omits the
// explicit term override and therefore uses the current term.
// Preserve that path until its cross-device semantics are reviewed.
return result(ret, "saved", [
{
mode: "v2",
timing: "fire-and-forget",
path: this.dependencies.path.filenameWithUnifiedKey(path),
},
]);
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return result(false, "failed");
}
});
}
async storeCustomizationFiles(path: FilePath, term: string): Promise<SnapshotPersistenceResult<StoreResultValue>> {
const vf = this.dependencies.path.filenameToUnifiedKey(path, term);
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
return await serialized(`plugin-${vf}`, async () => {
const category = this.dependencies.path.getFileCategory(path);
let mtime = 0;
let fileTargets = [] as FilePath[];
// let savePath = "";
const name =
category == "CONFIG" || category == "SNIPPET"
? path.split("/").reverse()[0]
: path.split("/").reverse()[1];
const parentPath = path.split("/").slice(0, -1).join("/");
const prefixedFileName = this.dependencies.path.filenameToUnifiedKey(path, term);
const id = await this.dependencies.path.path2id(prefixedFileName);
const dt: PluginDataEx = {
category: category,
files: [],
name: name,
mtime: 0,
term: term,
};
// let scheduleKey = "";
if (
category == "CONFIG" ||
category == "SNIPPET" ||
category == "PLUGIN_ETC" ||
category == "PLUGIN_DATA"
) {
fileTargets = [path];
if (category == "PLUGIN_ETC") {
dt.displayName = path.split("/").slice(-1).join("/");
}
} else if (category == "PLUGIN_MAIN") {
fileTargets = ["manifest.json", "main.js", "styles.css"].map((e) => `${parentPath}/${e}` as FilePath);
} else if (category == "THEME") {
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
}
for (const target of fileTargets) {
const data = await this.readFile(target);
if (data == false) {
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
continue;
}
if (data.version) {
dt.version = data.version;
}
if (data.displayName) {
dt.displayName = data.displayName;
}
// Compatibility question: the inherited aggregation uses an
// average rather than the newest member mtime. Preserve that
// scan behaviour until its timestamp policy is reviewed.
mtime = mtime == 0 ? data.mtime : (data.mtime + mtime) / 2;
dt.files.push(data);
}
dt.mtime = mtime;
// Compatibility question: the inherited empty-file path performs a
// deletion refresh and then an unconditional explicit refresh. Keep
// both outcomes, including the extra refresh when deletion succeeds.
if (dt.files.length == 0) {
this._log(`Nothing left: deleting.. ${path}`);
const deletion = await this.deleteConfigOnDatabase(prefixedFileName);
return result(undefined, deletion.status, [
...deletion.refreshes,
{ mode: "v1", timing: "await", path: prefixedFileName },
]);
}
const content = createTextBlob(serialize(dt));
try {
const old = await this.dependencies
.getLocalDatabase()
.getDBEntryMeta(prefixedFileName, undefined, false);
let saveData: SavingEntry;
if (old === false) {
saveData = {
_id: id,
path: prefixedFileName,
data: content,
mtime,
ctime: mtime,
datatype: "newnote",
size: content.size,
children: [],
deleted: false,
type: "newnote",
eden: {},
};
} else {
if (old.mtime == mtime) {
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
return result(true, "skipped");
}
const oldC = await this.dependencies.getLocalDatabase().getDBEntryFromMeta(old, false, false);
if (oldC) {
const d = deserialize(getDocDataAsArray(oldC.data), {}) as PluginDataEx;
if (d.files.length == dt.files.length) {
// Compatibility question: the inherited comparison
// looks up each current file by the previous filename
// and compares a missing lookup as empty content.
// Preserve this rename/empty-file behaviour for now.
const diffs = d.files
.map((previous) => ({
prev: previous,
curr: dt.files.find((e) => e.filename == previous.filename),
}))
.map(async (e) => {
try {
return await isDocContentSame(e.curr?.data ?? [], e.prev.data);
} catch {
return false;
}
});
const isSame = (await Promise.all(diffs)).every((e) => e == true);
if (isSame) {
this._log(
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
LOG_LEVEL_VERBOSE
);
return result(true, "skipped");
}
}
}
saveData = {
...old,
data: content,
mtime,
size: content.size,
datatype: "newnote",
children: [],
deleted: false,
type: "newnote",
};
}
const ret = await this.dependencies.getLocalDatabase().putDBEntry(saveData);
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
return result(ret, "saved", [{ mode: "v1", timing: "await", path: saveData.path }]);
} catch (ex) {
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return result(false, "failed");
}
});
}
// Compatibility question: the inherited forceWrite parameter is not read.
// Preserve it until callers define whether deletion should bypass a marker.
async deleteConfigOnDatabase(
prefixedFileName: FilePathWithPrefix,
forceWrite = false
): Promise<SnapshotPersistenceResult<boolean>> {
void forceWrite;
// const id = await this.path2id(prefixedFileName);
const mtime = new Date().getTime();
return await serialized("file-x-" + prefixedFileName, async () => {
try {
const old = (await this.dependencies
.getLocalDatabase()
.getDBEntryMeta(prefixedFileName, undefined, false)) as InternalFileEntry | false;
let saveData: InternalFileEntry;
if (old === false) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted (Not found on database)`);
return result(true, "missing");
} else {
if (old.deleted) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) already deleted`);
return result(true, "already-deleted");
}
saveData = {
...old,
mtime,
size: 0,
children: [],
deleted: true,
type: "newnote",
};
}
await this.dependencies.getLocalDatabase().putRaw(saveData);
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
return result(true, "deleted", [{ mode: "v1", timing: "await", path: prefixedFileName }]);
} catch (ex) {
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
this._log(ex, LOG_LEVEL_VERBOSE);
return result(false, "failed");
}
});
}
}
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
}));
vi.mock("@/common/utils.ts", () => ({
EVEN: Symbol("even"),
cancelTask: vi.fn(),
fireAndForget: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
vi.mock("@/features/optionalFileSyncFileTree.ts", () => ({
collectOptionalFileSyncFiles: vi.fn(),
}));
import type { FilePath, FilePathWithPrefix, LoadedEntry, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVEN } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
import { createCustomisationSyncCodec } from "./customisationSyncCodec.ts";
import { SnapshotPersistence, type SnapshotPersistenceDependencies } from "./snapshotPersistence.ts";
const CONFIG_PATH = ".obsidian/app.json" as FilePath;
const V1_PATH = "ix:device-a/CONFIG/app.json.md" as FilePathWithPrefix;
const V2_PATH = "ix:device-a/CONFIG/app.json%app.json" as FilePathWithPrefix;
const codec = createCustomisationSyncCodec({
digestHash: (source) => source.join(""),
parseYaml: () => undefined,
});
function loadedV2Entry(source: string, mtime = 10): LoadedEntry {
const data = `${codec.dummyHead}${codec.dummyEnd}${btoa(source)}`;
return {
_id: "entry-id",
_rev: "1-a",
path: V2_PATH,
type: "plain",
datatype: "plain",
data,
ctime: mtime,
mtime,
size: data.length,
children: [],
eden: {},
} as unknown as LoadedEntry;
}
function createPersistence(
options: {
category?: "CONFIG" | "PLUGIN_MAIN";
old?: false | LoadedEntry;
stat?: UXStat | null;
content?: string;
currentTerm?: string;
} = {}
) {
const currentTerm = options.currentTerm ?? "device-a";
const statHidden = vi.fn(
async (_path: string): Promise<UXStat | null> =>
options.stat === undefined ? { type: "file", ctime: 10, mtime: 10, size: 5 } : options.stat
);
const readHiddenFileBinary = vi.fn(
async (_path: string) => new TextEncoder().encode(options.content ?? "hello").buffer
);
const getDBEntryMeta = vi.fn(async () => options.old ?? false);
const getDBEntryFromMeta = vi.fn(async (entry: LoadedEntry) => entry);
const putDBEntry = vi.fn(async () => ({ ok: true, id: "entry-id", rev: "2-b" }));
const putRaw = vi.fn(async () => ({ ok: true, id: "entry-id", rev: "2-b" }));
const filenameToUnifiedKey = vi.fn(
(_path: string, term?: string) =>
`ix:${term ?? currentTerm}/${options.category ?? "CONFIG"}/app.json.md` as FilePathWithPrefix
);
const filenameWithUnifiedKey = vi.fn(
(_path: string, term?: string) =>
`ix:${term ?? currentTerm}/${options.category ?? "CONFIG"}/app.json%app.json` as FilePathWithPrefix
);
const dependencies: SnapshotPersistenceDependencies = {
getLocalDatabase: () => ({ getDBEntryMeta, getDBEntryFromMeta, putDBEntry, putRaw }),
storageAccess: { statHidden, readHiddenFileBinary },
path: {
getFileCategory: () => options.category ?? "CONFIG",
filenameToUnifiedKey,
filenameWithUnifiedKey,
path2id: vi.fn(async (path) => path),
isMarkedAsSameChanges: vi.fn(),
markChangesAreSame: vi.fn(),
},
log: vi.fn(),
getConfigDir: () => ".obsidian",
};
return {
database: { getDBEntryMeta, getDBEntryFromMeta, putDBEntry, putRaw },
dependencies,
filenameToUnifiedKey,
filenameWithUnifiedKey,
persistence: new SnapshotPersistence(dependencies),
readHiddenFileBinary,
statHidden,
};
}
describe("Customisation Sync snapshot persistence", () => {
it("persists a V2 file and returns a fire-and-forget catalogue refresh", async () => {
const fixture = createPersistence();
const mutation = await fixture.persistence.storeCustomisationFileV2(CONFIG_PATH, "device-a");
expect(mutation).toMatchObject({
value: { ok: true, id: "entry-id", rev: "2-b" },
status: "saved",
refreshes: [{ mode: "v2", timing: "fire-and-forget", path: V2_PATH }],
});
expect(fixture.database.putDBEntry).toHaveBeenCalledOnce();
expect(fixture.filenameWithUnifiedKey).toHaveBeenNthCalledWith(1, CONFIG_PATH, "device-a");
expect(fixture.filenameWithUnifiedKey).toHaveBeenNthCalledWith(2, CONFIG_PATH);
});
it("aggregates the V1 plug-in file set and returns an awaited refresh", async () => {
const fixture = createPersistence({ category: "PLUGIN_MAIN" });
const mutation = await fixture.persistence.storeCustomizationFiles(
".obsidian/plugins/example/main.js" as FilePath,
"device-a"
);
expect(mutation).toMatchObject({
value: { ok: true },
status: "saved",
refreshes: [{ mode: "v1", timing: "await", path: "ix:device-a/PLUGIN_MAIN/app.json.md" }],
});
expect(fixture.readHiddenFileBinary).toHaveBeenCalledTimes(3);
expect(fixture.database.putDBEntry).toHaveBeenCalledOnce();
});
it("keeps the inherited duplicate V1 refresh on the empty-file deletion path", async () => {
const old = {
...loadedV2Entry("old"),
path: V1_PATH,
datatype: "newnote",
type: "newnote",
deleted: false,
} as LoadedEntry;
const fixture = createPersistence({ old, stat: null });
const mutation = await fixture.persistence.storeCustomizationFiles(CONFIG_PATH, "device-a");
expect(mutation.value).toBeUndefined();
expect(mutation.status).toBe("deleted");
expect(mutation.refreshes).toEqual([
{ mode: "v1", timing: "await", path: V1_PATH },
{ mode: "v1", timing: "await", path: V1_PATH },
]);
expect(fixture.database.putRaw).toHaveBeenCalledOnce();
});
it.each([
["missing", false, "missing"],
["already deleted", { ...loadedV2Entry("old"), deleted: true } as LoadedEntry, "already-deleted"],
] as const)("treats an absent or %s document as a successful no-op", async (_label, old, status) => {
const fixture = createPersistence({ old, stat: null });
const mutation = await fixture.persistence.deleteConfigOnDatabase(V1_PATH);
expect(mutation).toMatchObject({ value: true, status, refreshes: [] });
expect(fixture.database.putRaw).not.toHaveBeenCalled();
});
it("returns an awaited refresh only when deletion writes a live document", async () => {
const old = {
...loadedV2Entry("old"),
path: V1_PATH,
deleted: false,
} as LoadedEntry;
const fixture = createPersistence({ old });
const mutation = await fixture.persistence.deleteConfigOnDatabase(V1_PATH);
expect(mutation).toMatchObject({
value: true,
status: "deleted",
refreshes: [{ mode: "v1", timing: "await", path: V1_PATH }],
});
expect(fixture.database.putRaw).toHaveBeenCalledOnce();
});
it("preserves the V2 marker and same-content skips", async () => {
const markerFixture = createPersistence({ old: loadedV2Entry("old") });
const marker = markerFixture.dependencies.path.isMarkedAsSameChanges as ReturnType<typeof vi.fn>;
marker.mockReturnValue(EVEN);
await expect(
markerFixture.persistence.storeCustomisationFileV2(CONFIG_PATH, "device-a")
).resolves.toMatchObject({
value: undefined,
status: "skipped",
refreshes: [],
});
expect(markerFixture.database.putDBEntry).not.toHaveBeenCalled();
const sameContentFixture = createPersistence({ old: loadedV2Entry("hello") });
await expect(
sameContentFixture.persistence.storeCustomisationFileV2(CONFIG_PATH, "device-a")
).resolves.toMatchObject({
value: true,
status: "skipped",
refreshes: [],
});
expect(sameContentFixture.database.putDBEntry).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,470 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
type DocumentID,
LOG_LEVEL_NOTICE,
type FilePath,
type FilePathWithPrefix,
type MetaEntry,
type UXFileInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
plugin!: { app: unknown };
core!: { services: unknown; settings: unknown };
get app() {
return this.plugin.app;
}
get services() {
return this.core.services;
}
get settings() {
return this.core.settings;
}
},
}));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
configureHiddenFileSyncMode: vi.fn(),
}));
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
function createHiddenRevisionOperation() {
const path = ".obsidian/plugins/example/data.json" as FilePath;
const file = {
path,
name: "data.json",
isInternal: true,
body: new Blob(["{\"value\":\"vault\"}"]),
stat: {
ctime: 1,
mtime: 2,
size: 17,
type: "file",
},
} as UXFileInfo;
const selected = {
_id: "i:example" as DocumentID,
_rev: "2-selected",
path: `i:${path}` as FilePathWithPrefix,
ctime: 1,
mtime: 2,
size: 17,
type: "plain",
datatype: "plain",
children: [],
eden: {},
deleted: false,
} as MetaEntry;
const winner = {
...selected,
_rev: "3-winner",
} as MetaEntry;
const databaseFileAccess = {
fetchEntryMeta: vi.fn(
async (_path: unknown, revision?: string) =>
revision === selected._rev ? selected : winner
),
getConflictedRevs: vi.fn(async () => [selected._rev]),
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })),
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
vault: {
isIgnoredByIgnoreFile: vi.fn(async () => false),
},
},
databaseFileAccess,
},
loadFileWithInfo: vi.fn(async () => file),
updateLastProcessed: vi.fn(),
_log: vi.fn(),
});
return {
hiddenFileSync,
path,
file,
selected,
winner,
databaseFileAccess,
};
}
describe("HiddenFileSync configuration-change notices", () => {
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
syncInternalFiles: false,
useAdvancedMode: false,
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings,
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
},
},
_isMainReady: vi.fn(() => true),
_isMainSuspended: vi.fn(() => false),
_isDatabaseReady: vi.fn(() => true),
});
hiddenFileSync.onload();
const commandIds = [
"livesync-sync-internal",
"livesync-scaninternal-storage",
"livesync-scaninternal-database",
"livesync-internal-scan-offline-changes",
];
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
}
settings.syncInternalFiles = true;
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(true);
}
});
it("does not report Hidden File Sync as ready before the main runtime is ready", () => {
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: {
syncInternalFiles: true,
},
},
_isMainReady: vi.fn(() => false),
_isMainSuspended: vi.fn(() => false),
});
expect(hiddenFileSync.isReady()).toBe(false);
});
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(() => true),
removeItem: vi.fn(() => true),
};
const plugin = {
app: {
plugins: {
manifests: {
alpha: {
id: "alpha",
name: "Alpha",
dir: ".obsidian/plugins/alpha",
},
beta: {
id: "beta",
name: "Beta",
dir: ".obsidian/plugins/beta",
},
},
enabledPlugins: new Set(["alpha", "beta"]),
unloadPlugin: vi.fn(async () => undefined),
loadPlugin: vi.fn(async () => undefined),
},
},
};
const core = {
confirm: { askInPopup: vi.fn() },
services: {
context: { noticeGroups },
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
appLifecycle: {
isReloadingScheduled: vi.fn(() => false),
scheduleRestart: vi.fn(),
},
},
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
plugin,
core,
queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]),
_log: vi.fn(),
});
hiddenFileSync.notifyConfigChange();
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "hidden-file-changes", "plugin:alpha", {
message: "Files in Alpha were updated.",
action: expect.objectContaining({ label: "Reload Alpha" }),
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "hidden-file-changes", "plugin:beta", {
message: "Files in Beta were updated.",
action: expect.objectContaining({ label: "Reload Beta" }),
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(3, "hidden-file-changes", "restart", {
message: "Other Obsidian settings files were updated.",
action: expect.objectContaining({ label: "Schedule an Obsidian restart" }),
});
expect(noticeGroups.setItem.mock.calls.every(([groupKey]) => groupKey === "hidden-file-changes")).toBe(true);
expect(noticeGroups.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 });
expect(core.confirm.askInPopup).not.toHaveBeenCalled();
const reloadAction = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect: () => void } }).action
.onSelect;
reloadAction();
await vi.waitFor(() => {
expect(plugin.app.plugins.unloadPlugin).toHaveBeenCalledWith("alpha");
expect(plugin.app.plugins.loadPlugin).toHaveBeenCalledWith("alpha");
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha");
});
const restartAction = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect: () => void } }).action
.onSelect;
restartAction();
expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce();
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
});
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const rebuildMerging = vi.fn(async () => []);
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
const scanAllStorageChanges = vi.fn(async () => undefined);
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
adoptCurrentDatabaseFilesAsProcessed,
scanAllStorageChanges,
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const events: string[] = [];
const progress = {
log: vi.fn((message: string) => {
events.push(`progress:${message}`);
}),
once: vi.fn(),
done: vi.fn(),
};
const createProgress = vi.fn(() => progress);
const applyPartial = vi.fn(async () => {
events.push("apply-settings");
});
const initialiseInternalFileSync = vi.fn(async () => undefined);
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
setting: { applyPartial },
},
},
initialiseInternalFileSync,
_progress: createProgress,
_log: log,
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
expect(events[0]).toBe("progress:Preparing Hidden File Sync...");
expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress);
expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE);
expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("closes the preparation Notice when enabling Hidden File Sync fails", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
return "enabled";
});
const error = new Error("setting persistence failed");
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
setting: {
applyPartial: vi.fn(async () => {
throw error;
}),
},
},
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
});
await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error);
expect(progress.done).toHaveBeenCalledWith("Failed");
});
});
describe("HiddenFileSync exact revision repair operations", () => {
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
).resolves.toBe(true);
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
expect.objectContaining({
path: file.path,
body: file.body,
isInternal: true,
}),
selected._rev,
true
);
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
file.path,
expect.objectContaining({ _rev: "3-vault-child" }),
file.stat
);
});
it("refuses to extend a hidden-file revision which is no longer live", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
).resolves.toBe(false);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
});
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
file,
selected._rev!,
false
)
).resolves.toBe(false);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
});
it("marks a matching hidden-file revision without creating a child", async () => {
const {
hiddenFileSync,
file,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
...selected,
data: "{\"value\":\"vault\"}",
});
await expect(
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
file,
selected._rev!,
false
)
).resolves.toBe(true);
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
file.path,
selected,
file.stat
);
});
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
const {
hiddenFileSync,
path,
selected,
} = createHiddenRevisionOperation();
const extract = vi.fn(async () => true);
hiddenFileSync.extractInternalFileFromDatabase = extract;
await expect(
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
).resolves.toBe(true);
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
});
it("does not apply a hidden-file revision which ceased to be live", async () => {
const {
hiddenFileSync,
path,
selected,
databaseFileAccess,
} = createHiddenRevisionOperation();
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
await expect(
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
).resolves.toBe(false);
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import type { FilePath, ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
const HIDDEN_FILE_NOTIFICATION_TASK = "notify-config-change";
const HIDDEN_FILE_NOTIFICATION_DELAY_MS = 1000;
export type HiddenFileSyncChangeNotifierSettings = Pick<ObsidianLiveSyncSettings, "suppressNotifyHiddenFilesChange">;
export type HiddenFileSyncChangeNotifierTaskScheduler = (
key: string,
timeout: number,
operation: () => Promise<unknown> | void
) => void;
export type HiddenFileSyncChangeNotifierDependencies = {
getSettings(): HiddenFileSyncChangeNotifierSettings;
getConfigDir(): string;
scheduleTask: HiddenFileSyncChangeNotifierTaskScheduler;
cancelTask(key: string): void;
showConfigurationChangeNotice(updatedFolders: readonly string[]): void;
hideConfigurationChangeNotice(): void;
};
export type HiddenFileSyncChangeNotifier = {
queueNotification(path: FilePath): void;
/** Compatibility seam used by the real-Obsidian Hidden File Sync fixture. */
showConfigurationChangeNotice(updatedFolders: readonly string[]): void;
dispose(): void;
};
class HiddenFileSyncChangeNotifierOwner implements HiddenFileSyncChangeNotifier {
private readonly queuedNotificationFiles = new Set<string>();
private disposed = false;
constructor(private readonly dependencies: HiddenFileSyncChangeNotifierDependencies) {}
queueNotification(path: FilePath): void {
if (this.disposed) return;
if (this.dependencies.getSettings().suppressNotifyHiddenFilesChange) return;
const configDir = this.dependencies.getConfigDir();
if (!path.startsWith(configDir)) return;
const folder = path.split("/").slice(0, -1).join("/");
this.queuedNotificationFiles.add(folder);
this.dependencies.scheduleTask(HIDDEN_FILE_NOTIFICATION_TASK, HIDDEN_FILE_NOTIFICATION_DELAY_MS, () => {
this.flush();
});
}
showConfigurationChangeNotice(updatedFolders: readonly string[]): void {
this.queuedNotificationFiles.clear();
for (const folder of updatedFolders) {
this.queuedNotificationFiles.add(folder);
}
this.flush();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.queuedNotificationFiles.clear();
this.dependencies.cancelTask(HIDDEN_FILE_NOTIFICATION_TASK);
this.dependencies.hideConfigurationChangeNotice();
}
private flush(): void {
const updatedFolders = [...this.queuedNotificationFiles];
this.queuedNotificationFiles.clear();
if (this.disposed) return;
this.dependencies.showConfigurationChangeNotice(updatedFolders);
}
}
export function createHiddenFileSyncChangeNotifier(
dependencies: HiddenFileSyncChangeNotifierDependencies
): HiddenFileSyncChangeNotifier {
return new HiddenFileSyncChangeNotifierOwner(dependencies);
}
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createHiddenFileSyncChangeNotifier,
type HiddenFileSyncChangeNotifierDependencies,
} from "./hiddenFileSyncChangeNotifier.ts";
type ScheduledOperation = {
key: string;
timeout: number;
operation: () => Promise<unknown> | void;
};
function createFixture(overrides: Partial<HiddenFileSyncChangeNotifierDependencies> = {}): {
notifier: ReturnType<typeof createHiddenFileSyncChangeNotifier>;
scheduled: ScheduledOperation[];
settings: { suppressNotifyHiddenFilesChange: boolean };
configDir: { value: string };
showConfigurationChangeNotice: ReturnType<typeof vi.fn>;
hideConfigurationChangeNotice: ReturnType<typeof vi.fn>;
scheduleTask: ReturnType<typeof vi.fn>;
cancelTask: ReturnType<typeof vi.fn>;
} {
const scheduled: ScheduledOperation[] = [];
const settings = { suppressNotifyHiddenFilesChange: false };
const configDir = { value: ".obsidian" };
const showConfigurationChangeNotice = vi.fn();
const hideConfigurationChangeNotice = vi.fn();
const scheduleTask = vi.fn<HiddenFileSyncChangeNotifierDependencies["scheduleTask"]>((key, timeout, operation) => {
scheduled.push({ key, timeout, operation });
});
const cancelTask = vi.fn<HiddenFileSyncChangeNotifierDependencies["cancelTask"]>();
const dependencies: HiddenFileSyncChangeNotifierDependencies = {
getSettings: () => settings,
getConfigDir: () => configDir.value,
scheduleTask,
cancelTask,
showConfigurationChangeNotice,
hideConfigurationChangeNotice,
...overrides,
};
return {
notifier: createHiddenFileSyncChangeNotifier(dependencies),
scheduled,
settings,
configDir,
showConfigurationChangeNotice,
hideConfigurationChangeNotice,
scheduleTask,
cancelTask,
};
}
describe("Hidden File Sync change notifier", () => {
it("queues distinct parent folders and flushes them in insertion order", () => {
const fixture = createFixture();
fixture.notifier.queueNotification(".obsidian/plugins/alpha/data.json" as FilePath);
fixture.notifier.queueNotification(".obsidian/plugins/beta/data.json" as FilePath);
fixture.notifier.queueNotification(".obsidian/plugins/alpha/main.js" as FilePath);
expect(fixture.scheduleTask).toHaveBeenCalledTimes(3);
expect(fixture.scheduleTask).toHaveBeenLastCalledWith("notify-config-change", 1000, expect.any(Function));
fixture.scheduled[fixture.scheduled.length - 1]?.operation();
expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith([
".obsidian/plugins/alpha",
".obsidian/plugins/beta",
]);
});
it("uses live suppression and configuration-directory dependencies", () => {
const fixture = createFixture();
fixture.settings.suppressNotifyHiddenFilesChange = true;
fixture.notifier.queueNotification(".obsidian/plugins/suppressed/data.json" as FilePath);
fixture.settings.suppressNotifyHiddenFilesChange = false;
fixture.notifier.queueNotification("other/plugins/outside/data.json" as FilePath);
fixture.configDir.value = "other";
fixture.notifier.queueNotification("other/plugins/inside/data.json" as FilePath);
expect(fixture.scheduled).toHaveLength(1);
fixture.scheduled[0]?.operation();
expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith(["other/plugins/inside"]);
});
it("clears the batch before displaying it", () => {
const fixture = createFixture();
fixture.showConfigurationChangeNotice.mockImplementation(() => {
fixture.notifier.queueNotification(".obsidian/plugins/new/data.json" as FilePath);
});
fixture.notifier.queueNotification(".obsidian/plugins/old/data.json" as FilePath);
fixture.scheduled[0]?.operation();
expect(fixture.showConfigurationChangeNotice).toHaveBeenNthCalledWith(1, [".obsidian/plugins/old"]);
fixture.scheduled[1]?.operation();
expect(fixture.showConfigurationChangeNotice).toHaveBeenNthCalledWith(2, [".obsidian/plugins/new"]);
});
it("supports the immediate fixture seam without scheduling another task", () => {
const fixture = createFixture();
fixture.notifier.showConfigurationChangeNotice([
".obsidian/plugins/alpha",
".obsidian/plugins/beta",
".obsidian/plugins/alpha",
]);
expect(fixture.showConfigurationChangeNotice).toHaveBeenCalledWith([
".obsidian/plugins/alpha",
".obsidian/plugins/beta",
]);
expect(fixture.scheduleTask).not.toHaveBeenCalled();
});
it("cancels pending work, hides the Notice, and ignores later work on disposal", () => {
const fixture = createFixture();
fixture.notifier.queueNotification(".obsidian/plugins/example/data.json" as FilePath);
fixture.notifier.dispose();
fixture.notifier.dispose();
fixture.scheduled[0]?.operation();
fixture.notifier.queueNotification(".obsidian/plugins/later/data.json" as FilePath);
expect(fixture.cancelTask).toHaveBeenCalledOnce();
expect(fixture.cancelTask).toHaveBeenCalledWith("notify-config-change");
expect(fixture.hideConfigurationChangeNotice).toHaveBeenCalledOnce();
expect(fixture.showConfigurationChangeNotice).not.toHaveBeenCalled();
expect(fixture.scheduleTask).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,260 @@
import {
LOG_LEVEL_DEBUG,
LOG_LEVEL_VERBOSE,
type FilePath,
type FilePathWithPrefix,
type LoadedEntry,
type LOG_LEVEL,
type MetaEntry,
type UXFileInfo,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { addPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { ICHeader } from "@/common/types.ts";
import type { HiddenFileSyncConflictResolution } from "./hiddenFileSyncConflictResolution.ts";
import type { HiddenFileSyncDatabaseExtractionOperations } from "./hiddenFileSyncDatabaseExtractionOperations.ts";
import type { HiddenFileSyncDatabaseWriteOperations } from "./hiddenFileSyncDatabaseWriteOperations.ts";
import type { HiddenFileSyncProcessedState } from "./hiddenFileSyncProcessedState.ts";
import { getHiddenFileSyncComparisonMTime } from "./hiddenFileSyncState.ts";
import { compareMTime, TARGET_IS_NEW } from "@/common/utils.ts";
type HiddenFileSyncStorageChangeAccess = Pick<StorageAccess, "statHidden">;
export type HiddenFileSyncChangeProcessorDependencies = {
storageAccess: HiddenFileSyncStorageChangeAccess;
readFileWithInfo(path: FilePath): Promise<UXFileInfo>;
loadDatabaseMetadata(path: FilePathWithPrefix): Promise<MetaEntry | LoadedEntry | false>;
databaseWriteOperations: Pick<HiddenFileSyncDatabaseWriteOperations, "store" | "delete">;
databaseExtractionOperations: Pick<HiddenFileSyncDatabaseExtractionOperations, "extract">;
processedState: Pick<
HiddenFileSyncProcessedState,
| "fileToStatKey"
| "getLastProcessedFileKey"
| "getLastProcessedFileMTime"
| "updateLastProcessedFile"
| "updateLastProcessed"
>;
conflictResolution: Pick<HiddenFileSyncConflictResolution, "queue">;
log: LogFunction;
publishActivity(eventCount: number, processingCount: number): void;
};
export type HiddenFileSyncDatabaseChangeOptions = Readonly<{
preventDoubleProcess?: boolean;
onlyNew?: boolean;
metaEntry?: MetaEntry | false;
includeDeletion?: boolean;
}>;
export type HiddenFileSyncChangeProcessor = {
processStorageChange(
path: FilePath,
onlyNew?: boolean,
forceWrite?: boolean,
includeDeleted?: boolean
): Promise<boolean | undefined>;
processDatabaseChange(
path: FilePath,
headerLine: string,
options?: HiddenFileSyncDatabaseChangeOptions
): Promise<boolean>;
dispose(): void;
};
class HiddenFileSyncChangeProcessorOwner implements HiddenFileSyncChangeProcessor {
private readonly semaphore = Semaphore(10);
private eventCount = 0;
private processingCount = 0;
private disposed = false;
constructor(private readonly dependencies: HiddenFileSyncChangeProcessorDependencies) {}
async processStorageChange(
path: FilePath,
onlyNew = false,
forceWrite = false,
includeDeleted = true
): Promise<boolean | undefined> {
try {
return await this.serialiseForEvent(path, async () => {
let stat = await this.dependencies.storageAccess.statHidden(path);
// Sometimes a folder is delivered as a file event.
if (stat != null && stat.type != "file") {
return false;
}
const key = await this.dependencies.processedState.fileToStatKey(path, stat);
// A raw event can occur while the file is being read. Scans
// still enumerate every path, but event admission skips this
// exact already-settled key.
const lastKey = this.dependencies.processedState.getLastProcessedFileKey(path);
if (lastKey == key) {
this.log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
return true;
}
// Read the stat and content as one operation. The stat is
// deliberately compared again below: a file can change while
// the first stat is in flight.
const fileInfo = await this.dependencies.readFileWithInfo(path);
const cacheMTime = getHiddenFileSyncComparisonMTime(fileInfo.stat);
const statMtime = getHiddenFileSyncComparisonMTime(stat);
if (cacheMTime != statMtime) {
this.log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
stat = fileInfo.stat;
}
// Compatibility: the storage marker advances before the
// database operation. A later write failure can therefore
// leave this event marked as processed until a scan or state
// change causes it to be reconsidered.
this.dependencies.processedState.updateLastProcessedFile(path, stat!);
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
const nowIsNotFound = fileInfo.deleted;
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
if (type == "invalid") {
// Maybe the folder was deleted.
return false;
}
const storageMTimeActual = getHiddenFileSyncComparisonMTime(stat);
const storageMTime =
storageMTimeActual == 0
? this.dependencies.processedState.getLastProcessedFileMTime(path)
: storageMTimeActual;
if (onlyNew) {
const prefixedFileName = addPrefix(path, ICHeader);
const fileOnDatabase = await this.dependencies.loadDatabaseMetadata(prefixedFileName);
const databaseMTime = getHiddenFileSyncComparisonMTime(fileOnDatabase, includeDeleted);
const difference = compareMTime(storageMTime, databaseMTime);
if (difference != TARGET_IS_NEW) {
this.log(`Hidden file:${path} is not new.`, LOG_LEVEL_VERBOSE);
// OnlyNew does not handle a deletion. Preserve the
// inherited partial settlement when both values exist.
if (fileOnDatabase && stat) {
this.dependencies.processedState.updateLastProcessed(path, fileOnDatabase, stat);
}
return true;
}
}
if (type == "delete") {
this.log(`Deletion detected: ${path}`);
return await this.dependencies.databaseWriteOperations.delete(path, forceWrite);
}
if (type == "modified") {
this.log(`Modification detected:${path}`, LOG_LEVEL_VERBOSE);
const result = await this.dependencies.databaseWriteOperations.store(fileInfo, forceWrite);
const resultText = result === undefined ? "Nothing changed" : result ? "Updated" : "Failed";
this.log(`${resultText}: ${path} ${resultText}`, LOG_LEVEL_VERBOSE);
return result;
}
return false;
});
} catch (error) {
this.log(`Failed to process hidden file:${path}`);
this.log(error, LOG_LEVEL_VERBOSE);
}
// Could not be processed, but it was this operation's event. Return
// true to prevent a later handler from claiming it.
return true;
}
async processDatabaseChange(
path: FilePath,
headerLine: string,
options: HiddenFileSyncDatabaseChangeOptions = {}
): Promise<boolean> {
const {
preventDoubleProcess = false,
onlyNew = false,
metaEntry = false,
includeDeletion = true,
} = options;
return await this.serialiseForEvent(path, async () => {
try {
const prefixedPath = addPrefix(path, ICHeader);
const docMeta = metaEntry
? metaEntry
: await this.dependencies.loadDatabaseMetadata(prefixedPath);
if (docMeta === false) {
this.log(`${headerLine}: Failed to read detail of ${path}`);
throw new Error(`Failed to read detail ${path}`);
}
if (docMeta._conflicts && docMeta._conflicts.length > 0) {
this.dependencies.conflictResolution.queue(path);
this.log(`${headerLine} Hidden file conflicted, enqueued to resolve`);
return true;
}
const extracted = await this.dependencies.databaseExtractionOperations.extract(path, {
metaEntry: docMeta,
preventDoubleProcess,
onlyNew,
includeDeletion,
});
if (extracted) {
this.log(`${headerLine} Hidden file processed`);
}
} catch (error) {
this.log(`${headerLine} Failed to process hidden file`);
this.log(error, LOG_LEVEL_VERBOSE);
}
// Compatibility: recognition consumes the database event even when
// extraction returned false or threw. A later scan or state change,
// rather than handler fall-through, is responsible for retrying it.
return true;
});
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.eventCount = 0;
this.processingCount = 0;
this.publishActivity();
}
private async serialiseForEvent<Result>(file: FilePath, operation: () => Promise<Result>): Promise<Result> {
this.eventCount++;
this.publishActivity();
const release = await this.semaphore.acquire();
try {
return await serialized(`hidden-file-event:${file}`, async () => {
this.processingCount++;
this.publishActivity();
try {
return await operation();
} finally {
this.processingCount = Math.max(0, this.processingCount - 1);
this.publishActivity();
}
});
} finally {
release();
this.eventCount = Math.max(0, this.eventCount - 1);
this.publishActivity();
}
}
private publishActivity(): void {
this.dependencies.publishActivity(
this.disposed ? 0 : this.eventCount,
this.disposed ? 0 : this.processingCount
);
}
private log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
}
export function createHiddenFileSyncChangeProcessor(
dependencies: HiddenFileSyncChangeProcessorDependencies
): HiddenFileSyncChangeProcessor {
return new HiddenFileSyncChangeProcessorOwner(dependencies);
}
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from "vitest";
import type { FilePath, MetaEntry, UXFileInfo, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
import {
createHiddenFileSyncChangeProcessor,
type HiddenFileSyncChangeProcessorDependencies,
} from "./hiddenFileSyncChangeProcessor.ts";
const path = ".obsidian/app.json" as FilePath;
const stat = { ctime: 1, mtime: 2, size: 3, type: "file" } as UXStat;
function fileInfo(): UXFileInfo {
return {
path,
name: "app.json",
isInternal: true,
deleted: false,
body: new Blob(["{}"]),
stat,
} as UXFileInfo;
}
function metadata(): MetaEntry {
return {
_id: "i:app",
_rev: "2-current",
path: `i:${path}`,
type: "plain",
datatype: "plain",
ctime: 1,
mtime: 2,
size: 3,
children: [],
eden: {},
deleted: false,
} as unknown as MetaEntry;
}
function createDependencies(
overrides: Partial<HiddenFileSyncChangeProcessorDependencies> = {}
): HiddenFileSyncChangeProcessorDependencies {
const state = {
fileToStatKey: vi.fn(async () => "2-3"),
getLastProcessedFileKey: vi.fn(() => undefined),
getLastProcessedFileMTime: vi.fn(() => 0),
databaseStateKey: vi.fn(() => "2-3-2-current--1"),
getLastProcessedDatabaseKey: vi.fn(() => undefined),
updateLastProcessedFile: vi.fn(),
updateLastProcessedDatabase: vi.fn(),
updateLastProcessed: vi.fn(),
};
return {
storageAccess: {
statHidden: vi.fn(async () => stat),
},
readFileWithInfo: vi.fn(async () => fileInfo()),
loadDatabaseMetadata: vi.fn(async () => metadata()),
databaseWriteOperations: {
store: vi.fn(async () => true),
delete: vi.fn(async () => true),
},
databaseExtractionOperations: {
extract: vi.fn(async () => true),
},
processedState: state,
conflictResolution: { queue: vi.fn() },
log: vi.fn(),
publishActivity: vi.fn(),
...overrides,
} as HiddenFileSyncChangeProcessorDependencies;
}
describe("HiddenFileSyncChangeProcessor activity and serialisation", () => {
it("publishes admission, processing, and release transitions", async () => {
const dependencies = createDependencies();
const processor = createHiddenFileSyncChangeProcessor(dependencies);
await expect(processor.processStorageChange(path)).resolves.toBe(true);
const publishActivity = vi.mocked(dependencies.publishActivity);
expect(publishActivity.mock.calls).toEqual([
[1, 0],
[1, 1],
[1, 0],
[0, 0],
]);
processor.dispose();
});
it("serialises same-path storage changes while allowing each event to settle", async () => {
let active = 0;
let maximumActive = 0;
let releaseFirst!: () => void;
const firstStarted = new Promise<void>((resolve) => {
const write = resolve;
releaseFirst = write;
});
const dependencies = createDependencies({
databaseWriteOperations: {
store: vi.fn(async () => {
active++;
maximumActive = Math.max(maximumActive, active);
if (active == 1) await firstStarted;
active--;
return true;
}),
delete: vi.fn(async () => true),
},
});
const processor = createHiddenFileSyncChangeProcessor(dependencies);
const first = processor.processStorageChange(path);
await vi.waitFor(() => expect(dependencies.databaseWriteOperations.store).toHaveBeenCalledOnce());
const second = processor.processStorageChange(path);
await new Promise<void>((resolve) => setTimeout(resolve, 0));
expect(dependencies.databaseWriteOperations.store).toHaveBeenCalledOnce();
releaseFirst();
await expect(first).resolves.toBe(true);
await expect(second).resolves.toBe(true);
expect(maximumActive).toBe(1);
expect(dependencies.databaseWriteOperations.store).toHaveBeenCalledTimes(2);
processor.dispose();
});
});
describe("HiddenFileSyncChangeProcessor compatibility settlement", () => {
it("consumes database events when metadata loading fails", async () => {
const error = new Error("metadata unavailable");
const dependencies = createDependencies({
loadDatabaseMetadata: vi.fn(async () => {
throw error;
}),
});
const processor = createHiddenFileSyncChangeProcessor(dependencies);
await expect(processor.processDatabaseChange(path, "[Replication]")).resolves.toBe(true);
expect(dependencies.log).toHaveBeenCalledWith("[Replication] Failed to process hidden file", undefined, undefined);
expect(dependencies.log).toHaveBeenCalledWith(error, expect.any(Number), undefined);
processor.dispose();
});
it("advances the storage marker before a failed database write", async () => {
const dependencies = createDependencies({
databaseWriteOperations: {
store: vi.fn(async () => false),
delete: vi.fn(async () => true),
},
});
const processor = createHiddenFileSyncChangeProcessor(dependencies);
await expect(processor.processStorageChange(path)).resolves.toBe(false);
expect(dependencies.processedState.updateLastProcessedFile).toHaveBeenCalledWith(path, stat);
expect(dependencies.databaseWriteOperations.store).toHaveBeenCalledOnce();
processor.dispose();
});
});
@@ -0,0 +1,426 @@
import {
LOG_LEVEL_INFO,
LOG_LEVEL_VERBOSE,
type DocumentID,
type FilePath,
type FilePathWithPrefix,
type LoadedEntry,
type LOG_LEVEL,
type MetaEntry,
type UXStat,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { isInternalMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import type { InternalFileInfo } from "@/common/types.ts";
import { getHiddenFileSyncComparisonMTime } from "./hiddenFileSyncState.ts";
export type HiddenFileSyncConflictPath = FilePath | FilePathWithPrefix;
export type HiddenFileSyncRevisionInfo = {
rev: string;
status: string;
};
export type HiddenFileSyncRevisionHistory = MetaEntry & {
_revs_info?: HiddenFileSyncRevisionInfo[];
};
export type HiddenFileSyncJsonResolution = {
keepRevision?: string;
mergedText?: string;
};
export type HiddenFileSyncConflictDatabase = {
scanConflictedEntries(): AsyncIterable<MetaEntry>;
getDocumentId(path: HiddenFileSyncConflictPath): Promise<DocumentID>;
loadCurrentMetadata(id: DocumentID): Promise<MetaEntry>;
loadConflictingMetadata(id: DocumentID, revision: string): Promise<MetaEntry>;
loadRevisionHistory(id: DocumentID): Promise<HiddenFileSyncRevisionHistory>;
loadRevisionEntry(path: HiddenFileSyncConflictPath, revision: string): Promise<LoadedEntry | false>;
mergeJson(
path: FilePathWithPrefix,
baseRevision: string,
currentRevision: string,
conflictedRevision: string
): Promise<string | false>;
removeRevision(id: DocumentID, revision: string): Promise<unknown>;
deleteRevision(entry: LoadedEntry): Promise<boolean>;
};
export type HiddenFileSyncConflictStorage = {
ensureDirectory(path: FilePath): Promise<void>;
writeFile(path: FilePath, data: string): Promise<UXStat | null>;
triggerEvent(path: FilePath): Promise<void>;
};
export type HiddenFileSyncConflictReconciliation = {
storeFile(file: InternalFileInfo, forceWrite?: boolean): Promise<boolean | undefined>;
extractFile(path: FilePath): Promise<boolean | undefined>;
};
export type HiddenFileSyncConflictInteraction = {
resolveJsonConflict(
path: FilePath,
docs: [LoadedEntry, LoadedEntry],
apply: (resolution: HiddenFileSyncJsonResolution) => Promise<boolean>
): Promise<boolean>;
};
/** Read-only queue counters retained for the real-Obsidian contract tests. */
export type HiddenFileSyncConflictProcessorTestingView = {
readonly remaining: number;
readonly totalRemaining: number;
readonly nowProcessing: number;
};
/** Focused conflict operations exposed through the Hidden File Sync test view. */
export interface HiddenFileSyncConflictTestingView {
resolveAll(): Promise<void>;
resolveJson(docA: LoadedEntry, docB: LoadedEntry): Promise<boolean>;
readonly pendingPaths: readonly HiddenFileSyncConflictPath[];
readonly processor: HiddenFileSyncConflictProcessorTestingView;
}
export type HiddenFileSyncConflictResolutionDependencies = {
database: HiddenFileSyncConflictDatabase;
storage: HiddenFileSyncConflictStorage;
reconciliation: HiddenFileSyncConflictReconciliation;
interaction: HiddenFileSyncConflictInteraction;
shouldOverwrite(path: FilePath): boolean;
log: LogFunction;
};
export interface HiddenFileSyncConflictResolution {
queue(path: HiddenFileSyncConflictPath): void;
resolveAll(): Promise<void>;
resolveJson(docA: LoadedEntry, docB: LoadedEntry): Promise<boolean>;
dispose(): void;
readonly testing: HiddenFileSyncConflictTestingView;
}
type PendingJsonConflict = {
id: DocumentID;
doc: MetaEntry;
path: HiddenFileSyncConflictPath;
revA: string;
revB: string;
};
export function selectHiddenFileSyncRevisionToDelete(
currentDoc: MetaEntry,
currentRevision: string,
conflictedDoc: MetaEntry,
conflictedRevision: string
): string {
const currentMTime = getHiddenFileSyncComparisonMTime(currentDoc, true);
const conflictedMTime = getHiddenFileSyncComparisonMTime(conflictedDoc, true);
// Compatibility: an equal mtime keeps the current leaf and deletes the
// conflicted leaf. A different tie-breaker would alter existing winners.
return currentMTime < conflictedMTime ? currentRevision : conflictedRevision;
}
export function findHiddenFileSyncMergeBase(
revisions: readonly HiddenFileSyncRevisionInfo[] | undefined,
conflictedRevision: string
): string {
const conflictedGeneration = Number(conflictedRevision.split("-")[0]);
// Compatibility question: this is the first available lower generation
// from the current branch, not a proven nearest shared ancestor. Changing
// it requires a separate conflict-history decision.
return (
revisions?.find(({ rev, status }) => status == "available" && Number(rev.split("-")[0]) < conflictedGeneration)
?.rev ?? ""
);
}
class HiddenFileSyncConflictResolutionOwner implements HiddenFileSyncConflictResolution {
private readonly pendingPaths = new Set<HiddenFileSyncConflictPath>();
private readonly processor: QueueProcessor<HiddenFileSyncConflictPath, PendingJsonConflict>;
private disposed = false;
readonly testing: HiddenFileSyncConflictTestingView;
constructor(private readonly dependencies: HiddenFileSyncConflictResolutionDependencies) {
const interactionProcessor = new QueueProcessor<PendingJsonConflict, void>(
async (results) => {
const { id, doc, path, revA, revB } = results[0];
// Compatibility question: these reads intentionally remain
// outside the catch below. A rejected read can leave the path
// pending until another lifecycle event reconstructs the owner.
const docAMerge = await this.dependencies.database.loadRevisionEntry(path, revA);
const docBMerge = await this.dependencies.database.loadRevisionEntry(path, revB);
try {
if (docAMerge != false && docBMerge != false) {
if (await this.resolveJson(docAMerge, docBMerge)) {
this.requeue(path);
} else {
this.finish(path);
}
return;
}
await this.resolveByNewerEntry(id, path, doc, revA, revB);
} catch (error) {
this.finish(path);
throw error;
}
},
{
suspended: false,
batchSize: 1,
concurrentLimit: 1,
delay: 10,
keepResultUntilDownstreamConnected: false,
yieldThreshold: 10,
}
);
this.processor = new QueueProcessor<HiddenFileSyncConflictPath, PendingJsonConflict>(
async (paths) => await this.processPath(paths[0]),
{
suspended: false,
batchSize: 1,
concurrentLimit: 5,
delay: 10,
keepResultUntilDownstreamConnected: true,
yieldThreshold: 10,
pipeTo: interactionProcessor,
}
);
const pendingPaths = () => [...this.pendingPaths];
const processor = this.processor;
const processorView = Object.freeze({
get remaining() {
return processor.remaining;
},
get totalRemaining() {
return processor.totalRemaining;
},
get nowProcessing() {
return processor.nowProcessing;
},
});
this.testing = Object.freeze({
resolveAll: async () => await this.resolveAll(),
resolveJson: async (docA: LoadedEntry, docB: LoadedEntry) => await this.resolveJson(docA, docB),
get pendingPaths() {
return pendingPaths();
},
processor: processorView,
});
}
queue(path: HiddenFileSyncConflictPath): void {
if (this.disposed) return;
// Compatibility: this deliberately deduplicates exact strings only.
// Prefixed and unprefixed forms of one path can therefore coexist.
if (this.pendingPaths.has(path)) return;
this.pendingPaths.add(path);
// Compatibility question: if QueueProcessor throws during this
// synchronous admission, the pending marker is retained. No current
// caller expects enqueue to throw.
this.processor.enqueue(path);
}
async resolveAll(): Promise<void> {
// Creating the iterator and awaiting the completed pipeline remain
// outside the catch. Only iteration failures are logged and swallowed
// by this operation.
const conflicted = this.dependencies.database.scanConflictedEntries();
// Do not suspend ordinary conflict admission during the scan.
// QueueProcessor v2 can lose its resume event when scan completion
// races with the suspended pump, leaving every admitted path pending.
try {
for await (const doc of conflicted) {
if (!("_conflicts" in doc)) continue;
if (isInternalMetadata(doc._id)) {
this.queue(doc.path);
}
}
} catch (error) {
this.log("something went wrong on resolving all conflicted internal files");
this.log(error, LOG_LEVEL_VERBOSE);
}
await this.processor.waitForAllProcessed();
}
async resolveJson(docA: LoadedEntry, docB: LoadedEntry): Promise<boolean> {
this.log("Opening data-merging dialog", LOG_LEVEL_VERBOSE);
const docs: [LoadedEntry, LoadedEntry] = [docA, docB];
const storageFilePath = stripAllPrefixes(docA.path);
const displayFilename = `${storageFilePath}`;
return await this.dependencies.interaction.resolveJsonConflict(
storageFilePath,
docs,
async ({ keepRevision: keep, mergedText: result }) => {
try {
let needFlush = false;
if (!result && !keep) {
this.log(`Skipped merging: ${displayFilename}`);
return false;
}
// Compatibility question: the selected revision is not
// validated against these two documents. An unknown value
// consequently deletes both revisions without writing a
// merged result. The sequential effects are also not
// transactional, so an earlier deletion survives a later
// failure.
for (const doc of docs) {
if (doc._rev != keep) {
if (await this.dependencies.database.deleteRevision(doc)) {
this.log(`Conflicted revision has been deleted: ${displayFilename}`);
needFlush = true;
}
}
}
if (!keep && result) {
await this.dependencies.storage.ensureDirectory(storageFilePath);
const stat = await this.dependencies.storage.writeFile(storageFilePath, result);
if (!stat) {
throw new Error("Stat failed");
}
const mtime = getHiddenFileSyncComparisonMTime(stat);
// Compatibility: interactive merged text forces the
// database write, whereas automatic merge below uses
// the writer's default admission policy.
await this.dependencies.reconciliation.storeFile(
{
path: storageFilePath,
mtime,
ctime: stat.ctime ?? mtime,
size: stat.size ?? 0,
},
true
);
await this.dependencies.storage.triggerEvent(storageFilePath);
this.log(`STORAGE <-- DB:${displayFilename}: written (hidden,merged)`);
}
if (needFlush) {
if (await this.dependencies.reconciliation.extractFile(storageFilePath)) {
this.log(`STORAGE --> DB:${displayFilename}: extracted (hidden,merged)`);
} else {
this.log(`STORAGE --> DB:${displayFilename}: extracted (hidden,merged) Failed`);
}
}
return true;
} catch (error) {
this.log("Could not merge conflicted json");
this.log(error, LOG_LEVEL_VERBOSE);
return false;
}
}
);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
// QueueProcessor termination cascades downstream, but cannot cancel an
// already-running database operation or dialogue callback.
this.processor.terminate();
this.pendingPaths.clear();
}
private async processPath(path: HiddenFileSyncConflictPath): Promise<PendingJsonConflict[]> {
try {
const id = await this.dependencies.database.getDocumentId(path);
const doc = await this.dependencies.database.loadCurrentMetadata(id);
if (doc._conflicts === undefined || doc._conflicts.length == 0) {
this.finish(path);
return [];
}
this.log(`Hidden file conflicted:${path}`);
// Compatibility: sorting mutates the loaded Metadata object before
// it is forwarded to the manual-resolution stage.
const conflicts = doc._conflicts.sort((a, b) => Number(a.split("-")[0]) - Number(b.split("-")[0]));
const revA = doc._rev!;
const revB = conflicts[0];
if (path.endsWith(".json")) {
const revisionHistory = await this.dependencies.database.loadRevisionHistory(id);
const commonBase = findHiddenFileSyncMergeBase(revisionHistory._revs_info, revB);
const result = await this.dependencies.database.mergeJson(doc.path, commonBase, revA, revB);
if (result) {
this.log(`Object merge:${path}`, LOG_LEVEL_INFO);
const filename = stripAllPrefixes(path);
await this.dependencies.storage.ensureDirectory(filename);
const stat = await this.dependencies.storage.writeFile(filename, result);
if (!stat) {
throw new Error(`HiddenFileSyncConflictResolution: Failed to stat file ${filename}`);
}
await this.dependencies.reconciliation.storeFile({ path: filename, ...stat });
// Compatibility question: extraction is attempted before
// the conflicted branch is removed, so its conflict guard
// normally refuses it. Requeueing eventually reflects the
// winner; changing the order needs a separate decision.
await this.dependencies.reconciliation.extractFile(filename);
await this.dependencies.database.removeRevision(id, revB);
this.requeue(path);
return [];
}
this.log(`Object merge is not applicable.`, LOG_LEVEL_VERBOSE);
if (this.dependencies.shouldOverwrite(stripAllPrefixes(path))) {
this.log(`Overwrite rule applied for conflicted hidden file: ${path}`, LOG_LEVEL_INFO);
await this.resolveByNewerEntry(id, path, doc, revA, revB);
return [];
}
return [{ path, revA, revB, id, doc }];
}
await this.resolveByNewerEntry(id, path, doc, revA, revB);
return [];
} catch (error) {
this.finish(path);
this.log(`Failed to resolve conflict (Hidden): ${path}`);
this.log(error, LOG_LEVEL_VERBOSE);
return [];
}
}
private async resolveByNewerEntry(
id: DocumentID,
path: HiddenFileSyncConflictPath,
currentDoc: MetaEntry,
currentRevision: string,
conflictedRevision: string
): Promise<void> {
const conflictedDoc = await this.dependencies.database.loadConflictingMetadata(id, conflictedRevision);
const revisionToDelete = selectHiddenFileSyncRevisionToDelete(
currentDoc,
currentRevision,
conflictedDoc,
conflictedRevision
);
// Compatibility: the database result is ignored. The following conflict
// read, rather than the deletion response, decides settlement.
await this.dependencies.database.removeRevision(id, revisionToDelete);
this.log(`Older one has been deleted:${path}`);
const current = await this.dependencies.database.loadCurrentMetadata(id);
if (current._conflicts?.length === 0) {
await this.dependencies.reconciliation.extractFile(stripAllPrefixes(path));
this.finish(path);
} else {
// Compatibility: an absent _conflicts field is not considered
// settled here, although the main path treats it as conflict-free.
this.requeue(path);
}
}
private finish(path: HiddenFileSyncConflictPath): void {
this.pendingPaths.delete(path);
}
private requeue(path: HiddenFileSyncConflictPath): void {
this.finish(path);
this.queue(path);
}
private log(message: unknown, level?: LOG_LEVEL, key?: string): void {
this.dependencies.log(message, level, key);
}
}
export function createHiddenFileSyncConflictResolution(
dependencies: HiddenFileSyncConflictResolutionDependencies
): HiddenFileSyncConflictResolution {
return new HiddenFileSyncConflictResolutionOwner(dependencies);
}
@@ -0,0 +1,422 @@
import { describe, expect, it, vi } from "vitest";
import {
LOG_LEVEL_VERBOSE,
type DocumentID,
type FilePath,
type FilePathWithPrefix,
type LoadedEntry,
type MetaEntry,
type UXStat,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
createHiddenFileSyncConflictResolution,
findHiddenFileSyncMergeBase,
selectHiddenFileSyncRevisionToDelete,
type HiddenFileSyncConflictDatabase,
type HiddenFileSyncConflictInteraction,
type HiddenFileSyncConflictReconciliation,
type HiddenFileSyncConflictResolutionDependencies,
type HiddenFileSyncConflictStorage,
type HiddenFileSyncJsonResolution,
type HiddenFileSyncRevisionHistory,
} from "./hiddenFileSyncConflictResolution.ts";
const path = ".obsidian/plugins/example/data.json" as FilePath;
const prefixedPath = `i:${path}` as FilePathWithPrefix;
const id = "i:hidden-entry-id" as DocumentID;
function metadata(
revision: string,
mtime: number,
overrides: Partial<HiddenFileSyncRevisionHistory> = {}
): HiddenFileSyncRevisionHistory {
return {
_id: id,
_rev: revision,
path: prefixedPath,
type: "plain",
datatype: "plain",
ctime: 10,
mtime,
size: 20,
children: [],
eden: {},
deleted: false,
...overrides,
} as unknown as MetaEntry;
}
function loadedEntry(revision: string, content: string): LoadedEntry {
return {
...metadata(revision, 20),
data: content,
} as LoadedEntry;
}
function entries(...values: MetaEntry[]): AsyncIterable<MetaEntry> {
return {
async *[Symbol.asyncIterator]() {
yield* values;
},
};
}
type DependencyOverrides = {
database?: Partial<HiddenFileSyncConflictDatabase>;
storage?: Partial<HiddenFileSyncConflictStorage>;
reconciliation?: Partial<HiddenFileSyncConflictReconciliation>;
interaction?: Partial<HiddenFileSyncConflictInteraction>;
shouldOverwrite?: HiddenFileSyncConflictResolutionDependencies["shouldOverwrite"];
log?: HiddenFileSyncConflictResolutionDependencies["log"];
};
function createDependencies(overrides: DependencyOverrides = {}): HiddenFileSyncConflictResolutionDependencies {
const database: HiddenFileSyncConflictDatabase = {
scanConflictedEntries: () => entries(),
getDocumentId: vi.fn(async () => id),
loadCurrentMetadata: vi.fn(async () => metadata("1-current", 10)),
loadConflictingMetadata: vi.fn(async () => metadata("1-conflict", 10)),
loadRevisionHistory: vi.fn(async () => metadata("1-current", 10, { _revs_info: [] })),
loadRevisionEntry: vi.fn(async (): Promise<LoadedEntry | false> => false),
mergeJson: vi.fn(async (): Promise<string | false> => false),
removeRevision: vi.fn(async () => true),
deleteRevision: vi.fn(async () => true),
...overrides.database,
};
const storage: HiddenFileSyncConflictStorage = {
ensureDirectory: vi.fn(async () => undefined),
writeFile: vi.fn(async () => null),
triggerEvent: vi.fn(async () => undefined),
...overrides.storage,
};
const reconciliation: HiddenFileSyncConflictReconciliation = {
storeFile: vi.fn(async () => true),
extractFile: vi.fn(async () => true),
...overrides.reconciliation,
};
const interaction: HiddenFileSyncConflictInteraction = {
resolveJsonConflict: vi.fn(async () => false),
...overrides.interaction,
};
return {
database,
storage,
reconciliation,
interaction,
shouldOverwrite: overrides.shouldOverwrite ?? (() => false),
log: overrides.log ?? vi.fn(),
};
}
describe("Hidden File Sync conflict policy", () => {
it("keeps the current revision when both mtimes are equal", () => {
const current = metadata("3-current", 20);
const conflicted = metadata("2-conflict", 20);
expect(selectHiddenFileSyncRevisionToDelete(current, current._rev!, conflicted, conflicted._rev!)).toBe(
conflicted._rev
);
});
it("selects the first available lower-generation revision as the merge base", () => {
expect(
findHiddenFileSyncMergeBase(
[
{ rev: "4-current", status: "available" },
{ rev: "3-missing", status: "missing" },
{ rev: "2-base", status: "available" },
{ rev: "1-older", status: "available" },
],
"3-conflict"
)
).toBe("2-base");
expect(findHiddenFileSyncMergeBase(undefined, "3-conflict")).toBe("");
});
});
describe("Hidden File Sync conflict queue", () => {
it("continues processing queued conflict notifications while a full scan is in progress", async () => {
let releaseScan!: () => void;
let markScanStarted!: () => void;
const scanGate = new Promise<void>((resolve) => {
releaseScan = resolve;
});
const scanStarted = new Promise<void>((resolve) => {
markScanStarted = resolve;
});
const loadCurrentMetadata = vi.fn(async () => metadata("1-current", 10));
const dependencies = createDependencies({
database: {
scanConflictedEntries: () => ({
async *[Symbol.asyncIterator]() {
markScanStarted();
await scanGate;
},
}),
loadCurrentMetadata,
},
});
const resolution = createHiddenFileSyncConflictResolution(dependencies);
const resolvingAll = resolution.resolveAll();
await scanStarted;
// Cross the macrotask boundary which allowed the legacy suspended
// processor to stop before a database notification arrived.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
resolution.queue(prefixedPath);
try {
await vi.waitFor(() => expect(loadCurrentMetadata).toHaveBeenCalledOnce(), {
interval: 10,
timeout: 250,
});
} finally {
releaseScan();
await resolvingAll;
resolution.dispose();
}
});
it("deduplicates exact paths and does not accept work after disposal", async () => {
const loadCurrentMetadata = vi.fn(async () => metadata("1-current", 10));
const dependencies = createDependencies({ database: { loadCurrentMetadata } });
const resolution = createHiddenFileSyncConflictResolution(dependencies);
resolution.queue(prefixedPath);
resolution.queue(prefixedPath);
await resolution.resolveAll();
expect(loadCurrentMetadata).toHaveBeenCalledOnce();
resolution.dispose();
resolution.queue(`i:${path}.other` as FilePathWithPrefix);
expect(loadCurrentMetadata).toHaveBeenCalledOnce();
});
it("retains prefixed and unprefixed path forms as separate compatibility keys", async () => {
const loadCurrentMetadata = vi.fn(async () => metadata("1-current", 10));
const dependencies = createDependencies({ database: { loadCurrentMetadata } });
const resolution = createHiddenFileSyncConflictResolution(dependencies);
resolution.queue(path);
resolution.queue(prefixedPath);
await resolution.resolveAll();
expect(loadCurrentMetadata).toHaveBeenCalledTimes(2);
resolution.dispose();
});
it("deletes the conflicted revision on an mtime tie, then extracts", async () => {
const events: string[] = [];
const current = metadata("3-current", 20, { _conflicts: ["2-conflict"] });
const settled = metadata("3-current", 20, { _conflicts: [] });
const dependencies = createDependencies({
database: {
scanConflictedEntries: () => entries(current),
loadCurrentMetadata: vi.fn().mockResolvedValueOnce(current).mockResolvedValueOnce(settled),
loadConflictingMetadata: vi.fn(async () => metadata("2-conflict", 20)),
removeRevision: vi.fn(async (_id, revision) => {
events.push(`remove:${revision}`);
return true;
}),
},
reconciliation: {
extractFile: vi.fn(async () => {
events.push("extract");
return true;
}),
},
});
const resolution = createHiddenFileSyncConflictResolution(dependencies);
await resolution.resolveAll();
expect(events).toEqual(["remove:2-conflict", "extract"]);
resolution.dispose();
});
it("stores and extracts an automatic merge before removing the conflicted revision", async () => {
const events: string[] = [];
const current = metadata("3-current", 30, { _conflicts: ["2-conflict"] });
const settled = metadata("4-merged", 40, { _conflicts: [] });
const stat = { ctime: 10, mtime: 20, size: 30, type: "file" } as UXStat;
const mergeJson = vi.fn(async () => '{"merged":true}');
const dependencies = createDependencies({
database: {
scanConflictedEntries: () => entries(current),
loadCurrentMetadata: vi.fn().mockResolvedValueOnce(current).mockResolvedValueOnce(settled),
loadRevisionHistory: vi.fn(async () =>
metadata("3-current", 30, {
_revs_info: [
{ rev: "3-current", status: "available" },
{ rev: "1-base", status: "available" },
],
})
),
mergeJson,
removeRevision: vi.fn(async () => {
events.push("remove");
return true;
}),
},
storage: {
ensureDirectory: vi.fn(async () => {
events.push("ensure");
}),
writeFile: vi.fn(async () => {
events.push("write");
return stat;
}),
},
reconciliation: {
storeFile: vi.fn(async () => {
events.push("store");
return true;
}),
extractFile: vi.fn(async () => {
events.push("extract");
return false;
}),
},
});
const resolution = createHiddenFileSyncConflictResolution(dependencies);
await resolution.resolveAll();
expect(mergeJson).toHaveBeenCalledWith(prefixedPath, "1-base", "3-current", "2-conflict");
expect(events).toEqual(["ensure", "write", "store", "extract", "remove"]);
resolution.dispose();
});
});
describe("Hidden File Sync JSON conflict application", () => {
function createJsonResolutionFixture(
jsonResolution: HiddenFileSyncJsonResolution,
deletionResult: boolean | Error = true
) {
const events: string[] = [];
const docA = loadedEntry("3-current", '{"current":true}');
const docB = loadedEntry("2-conflict", '{"conflict":true}');
const deleteRevision = vi.fn(async (entry: LoadedEntry) => {
events.push(`delete:${entry._rev}`);
if (deletionResult instanceof Error) {
if (entry._rev === docB._rev) throw deletionResult;
return true;
}
return deletionResult;
});
const extractFile = vi.fn(async () => {
events.push("extract");
return false;
});
const storeFile = vi.fn(async () => {
events.push("store");
return true;
});
const stat = { ctime: 11, mtime: 21, size: 22, type: "file" } as UXStat;
const log = vi.fn();
const dependencies = createDependencies({
database: { deleteRevision },
storage: {
ensureDirectory: vi.fn(async () => {
events.push("ensure");
}),
writeFile: vi.fn(async () => {
events.push("write");
return stat;
}),
triggerEvent: vi.fn(async () => {
events.push("trigger");
}),
},
reconciliation: { extractFile, storeFile },
interaction: {
resolveJsonConflict: vi.fn(async (_path, _docs, apply) => await apply(jsonResolution)),
},
log,
});
return {
deleteRevision,
docA,
docB,
events,
extractFile,
log,
resolution: createHiddenFileSyncConflictResolution(dependencies),
stat,
storeFile,
};
}
it("returns false without changing data when no resolution is selected", async () => {
const fixture = createJsonResolutionFixture({});
await expect(fixture.resolution.resolveJson(fixture.docA, fixture.docB)).resolves.toBe(false);
expect(fixture.events).toEqual([]);
fixture.resolution.dispose();
});
it("keeps the selected revision but reports success when follow-up extraction fails", async () => {
const fixture = createJsonResolutionFixture({ keepRevision: "3-current" });
await expect(fixture.resolution.resolveJson(fixture.docA, fixture.docB)).resolves.toBe(true);
expect(fixture.deleteRevision).toHaveBeenCalledTimes(1);
expect(fixture.deleteRevision).toHaveBeenCalledWith(fixture.docB);
expect(fixture.events).toEqual([`delete:${fixture.docB._rev}`, "extract"]);
expect(fixture.log).toHaveBeenCalledWith(
`STORAGE --> DB:${path}: extracted (hidden,merged) Failed`,
undefined,
undefined
);
fixture.resolution.dispose();
});
it("deletes both supplied revisions when the selected revision is unknown", async () => {
const fixture = createJsonResolutionFixture({ keepRevision: "9-unknown" });
await expect(fixture.resolution.resolveJson(fixture.docA, fixture.docB)).resolves.toBe(true);
expect(fixture.events).toEqual([`delete:${fixture.docA._rev}`, `delete:${fixture.docB._rev}`, "extract"]);
expect(fixture.storeFile).not.toHaveBeenCalled();
fixture.resolution.dispose();
});
it("deletes both revisions before writing and storing a merged result", async () => {
const fixture = createJsonResolutionFixture({ mergedText: '{"merged":true}' });
await expect(fixture.resolution.resolveJson(fixture.docA, fixture.docB)).resolves.toBe(true);
expect(fixture.storeFile).toHaveBeenCalledWith(
{
path,
ctime: fixture.stat.ctime,
mtime: fixture.stat.mtime,
size: fixture.stat.size,
},
true
);
expect(fixture.events).toEqual([
`delete:${fixture.docA._rev}`,
`delete:${fixture.docB._rev}`,
"ensure",
"write",
"store",
"trigger",
"extract",
]);
fixture.resolution.dispose();
});
it("keeps an earlier successful deletion when a later deletion throws", async () => {
const error = new Error("second deletion failed");
const fixture = createJsonResolutionFixture({ mergedText: '{"merged":true}' }, error);
await expect(fixture.resolution.resolveJson(fixture.docA, fixture.docB)).resolves.toBe(false);
expect(fixture.events).toEqual([`delete:${fixture.docA._rev}`, `delete:${fixture.docB._rev}`]);
expect(fixture.storeFile).not.toHaveBeenCalled();
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE, undefined);
fixture.resolution.dispose();
});
});
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import {
type DocumentID,
type FilePath,
type FilePathWithPrefix,
type LoadedEntry,
type MetaEntry,
type UXStat,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ICHeader, ICHeaderEnd } from "@/common/types.ts";
vi.mock("@/deps.ts", () => ({}));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
configureHiddenFileSyncMode: vi.fn(),
}));
import { HiddenFileSyncContext } from "./hiddenFileSyncContext.ts";
describe("HiddenFileSyncContext operation composition", () => {
it("composes the conflict owner from the current database and path capabilities", async () => {
const path = ".obsidian/app.json" as FilePath;
const prefixedPath = `i:${path}` as FilePathWithPrefix;
const metadata = {
_id: "i:hidden-entry-id" as DocumentID,
_rev: "2-current",
path: prefixedPath,
type: "plain",
datatype: "plain",
ctime: 10,
mtime: 20,
size: 20,
children: [],
eden: {},
deleted: false,
_conflicts: [],
} as unknown as MetaEntry;
const findEntries = vi.fn(() => ({
async *[Symbol.asyncIterator]() {
yield metadata;
},
}));
const getRaw = vi.fn(async () => metadata);
const path2id = vi.fn(async () => metadata._id);
const periodicProcessor = { enable: vi.fn(), disable: vi.fn() };
const context = new HiddenFileSyncContext({
createPeriodicProcessor: vi.fn(() => periodicProcessor),
getLocalDatabase: () => ({ findEntries, getRaw }),
path: { path2id },
log: vi.fn(),
publishActivity: vi.fn(),
closeJsonConflictDialogs: vi.fn(),
hideConfigurationChangeNotice: vi.fn(),
} as never);
await context.testing.conflictResolution.resolveAll();
expect(findEntries).toHaveBeenCalledWith(ICHeader, ICHeaderEnd, { conflicts: true });
expect(path2id).toHaveBeenCalledWith(prefixedPath, ICHeader);
expect(getRaw).toHaveBeenCalledWith(metadata._id, { conflicts: true });
context.dispose();
});
it("applies a selected live revision through the narrow repair view", async () => {
const path = ".obsidian/plugins/example/data.json" as FilePath;
const prefixedPath = `i:${path}` as FilePathWithPrefix;
const revision = "2-selected";
const metadata = {
_id: "hidden-entry-id" as DocumentID,
_rev: revision,
path: prefixedPath,
type: "plain",
datatype: "plain",
ctime: 10,
mtime: 20,
size: 20,
children: [],
eden: {},
deleted: false,
} as unknown as MetaEntry;
const loaded = {
...metadata,
data: '{"value":"database"}',
} as LoadedEntry;
const stat = { ctime: 10, mtime: 20, size: 20, type: "file" } as UXStat;
const statHidden = vi.fn<() => Promise<UXStat | null>>().mockResolvedValueOnce(null).mockResolvedValue(stat);
const writeHiddenFileAuto = vi.fn(async () => true);
const getDBEntryFromMeta = vi.fn(async () => loaded);
const fetchEntryMeta = vi.fn(async () => metadata);
const getConflictedRevs = vi.fn(async () => [] as string[]);
const markChangesAreSame = vi.fn();
const periodicProcessor = { enable: vi.fn(), disable: vi.fn() };
const context = new HiddenFileSyncContext({
createPeriodicProcessor: vi.fn(() => periodicProcessor),
isIgnoredByIgnoreFile: vi.fn(async () => false),
databaseFileAccess: {
fetchEntryMeta,
getConflictedRevs,
},
getLocalDatabase: () => ({ getDBEntryFromMeta }),
storageAccess: {
statHidden,
isExistsIncludeHidden: vi.fn(async () => false),
ensureDir: vi.fn(async () => true),
writeHiddenFileAuto,
},
path: {
markChangesAreSame,
unmarkChanges: vi.fn(),
},
getSettings: () => ({ suppressNotifyHiddenFilesChange: true }),
log: vi.fn(),
publishActivity: vi.fn(),
closeJsonConflictDialogs: vi.fn(),
hideConfigurationChangeNotice: vi.fn(),
} as never);
await expect(context.repair.extractInternalFileRevisionFromDatabase(path, revision, true)).resolves.toBe(true);
expect(fetchEntryMeta).toHaveBeenCalledWith(prefixedPath, revision, true);
expect(getConflictedRevs).toHaveBeenCalledWith(prefixedPath);
expect(getDBEntryFromMeta).toHaveBeenCalledWith(metadata, false, true);
expect(writeHiddenFileAuto).toHaveBeenCalledWith(path, '{"value":"database"}', {
ctime: metadata.ctime,
mtime: metadata.mtime,
});
expect(markChangesAreSame).toHaveBeenCalledWith(path, metadata.mtime, stat.mtime);
context.dispose();
});
});

Some files were not shown because too many files have changed in this diff Show More