mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-04 17:57:05 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c85c1aef | ||
|
|
d2c32da30d | ||
|
|
c3e12cf946 | ||
|
|
c84383a44b | ||
|
|
b90ef3716c | ||
|
|
e1195629b9 | ||
|
|
0ecb73924a | ||
|
|
188b749326 | ||
|
|
6abc5cba64 | ||
|
|
045a328697 | ||
|
|
b6b9ce3ba1 | ||
|
|
f06f33cbf4 | ||
|
|
56a1a19d2c | ||
|
|
84be444689 | ||
|
|
54d276f5e5 | ||
|
|
f70bdbbbe0 | ||
|
|
22a835519b | ||
|
|
ad91776ad9 | ||
|
|
6ea906b575 | ||
|
|
338aecd888 | ||
|
|
3b2d5aa5af | ||
|
|
f055222160 | ||
|
|
77882c677b | ||
|
|
2461d37ead | ||
|
|
d00c5ecc56 | ||
|
|
7b4f7bf514 | ||
|
|
ba8f910865 | ||
|
|
c58e462057 | ||
|
|
a09c59aaba | ||
|
|
cbbae33d67 | ||
|
|
be9c328f4a | ||
|
|
a9b146a0ab | ||
|
|
5d5e448c6b | ||
|
|
725db213c3 | ||
|
|
a2441b9870 | ||
|
|
01c38268c7 | ||
|
|
64e17ab920 | ||
|
|
d8fccd6e4b | ||
|
|
4b47ebbd4d | ||
|
|
2c35f45765 | ||
|
|
caa8c92cbe | ||
|
|
2cefff43bb | ||
|
|
6b37ea8778 | ||
|
|
01c060558a |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -169,7 +173,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
|
||||
|
||||
@@ -229,6 +238,21 @@ Commonlib owns the typed English fallback for messages requested by its services
|
||||
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
||||
- This causes pretty significant performance overhead.
|
||||
|
||||
#### Diagnostic and notice ownership
|
||||
|
||||
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
|
||||
- Detailed diagnostics may be long and remain in English when they are intended for tracing and the generated report. Include enough context to identify the operation, affected target, and remaining state or retry behaviour.
|
||||
- The application boundary which owns the workflow should decide whether to raise `LOG_LEVEL_NOTICE`. It has the interaction context to describe the user-visible consequence and the next useful action; an internal stage description alone is not a useful notice.
|
||||
- When several files fail, issue one concise summary notice after the operation returns. Keep the per-file paths and technical causes at verbose level so that the notice remains readable and the generated report remains traceable.
|
||||
- Commonlib should raise a notice only when its contract explicitly owns user presentation and no higher-level caller can add the required workflow context.
|
||||
|
||||
The ordinary start-up scan provides a concrete comparison:
|
||||
|
||||
- Good verbose diagnostic: `Offline scan failed to synchronise ${path} between storage and the local database; this path remains eligible for a later scan.` It identifies the operation, the two states being reconciled, the exact target, and what can happen next. Its length is appropriate for a report.
|
||||
- Notice which needs more context: `Local database initialisation did not complete. See the log for details.` It describes an internal stage, but does not tell the user whether synchronisation can continue, what may be affected, or how to obtain the detailed log.
|
||||
- Good application notice for a partial result: `Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.` It states the observable consequence, gives a proportionate action, and leaves the per-file evidence in the report.
|
||||
- Good application notice for a failed result: `Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.` It states the operational consequence without exposing the internal initialisation stage.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Service feature implementation
|
||||
@@ -254,14 +278,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+193
-144
@@ -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,70 @@
|
||||
---
|
||||
date: 2026-09-04
|
||||
commonlib-version: "0.1.21"
|
||||
self-hosted-livesync-version: "1.0.24"
|
||||
status: unreleased
|
||||
---
|
||||
|
||||
# Path component length compatibility
|
||||
|
||||
## Purpose
|
||||
|
||||
File systems place limits on each file or folder name, rather than applying one
|
||||
common limit to an entire Vault-relative path. Those limits are also expressed
|
||||
in different units. Self-hosted LiveSync therefore treats 255 UTF-8 bytes as a
|
||||
focused Android and Linux compatibility warning, not as a universal definition
|
||||
of a valid path.
|
||||
|
||||
## Basis for the 255-byte warning
|
||||
|
||||
- The Linux kernel documentation gives ext4 a maximum file-name length of
|
||||
[255 bytes](https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html).
|
||||
- The F2FS on-disk header defines
|
||||
[`F2FS_NAME_LEN` as 255](https://android.googlesource.com/kernel/common/+/88d92fb1c034922572bab93482ac9cc61d4ba43c/include/linux/f2fs_fs.h)
|
||||
and stores names in byte arrays.
|
||||
- Android's MediaProvider uses a
|
||||
[`MAX_FILENAME_BYTES` value of 255](https://android.googlesource.com/platform/packages/providers/MediaProvider/+/bae279463/src/com/android/providers/media/util/FileUtils.java)
|
||||
when building file names. Its source notes that emulated storage can write to
|
||||
ext4 through FUSE, where names are encoded as UTF-8.
|
||||
- Android 11 and later use
|
||||
[FUSE for emulated storage](https://source.android.com/docs/core/storage/fuse-passthrough),
|
||||
with requests passing through to the underlying file system.
|
||||
|
||||
Together, these provide a conservative compatibility boundary for file names
|
||||
which may reach Android or Linux storage. They do not show that every Android
|
||||
device, storage provider, or Linux file system has the same limit.
|
||||
|
||||
## Why the rule is not universal
|
||||
|
||||
Other platforms describe component limits differently. Microsoft's file-system
|
||||
comparison documents limits in
|
||||
[Unicode characters](https://learn.microsoft.com/en-us/windows/win32/fileio/filesystem-functionality-comparison),
|
||||
not UTF-8 bytes. Apple's HFS Plus format stores a name as up to
|
||||
[255 16-bit `UniChar` values](https://developer.apple.com/library/archive/technotes/tn/tn1150.html).
|
||||
Apple's APFS guidance discusses valid UTF-8 names, normalisation, and case
|
||||
sensitivity, but does not establish a universal
|
||||
[255-byte component rule](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html).
|
||||
|
||||
A name can consequently exceed 255 UTF-8 bytes and still work on one platform,
|
||||
or fail for another platform-specific reason while remaining below this
|
||||
boundary.
|
||||
|
||||
## Product policy
|
||||
|
||||
Self-hosted LiveSync applies the warning as follows:
|
||||
|
||||
1. split the Vault-relative path on `/` and inspect each non-empty component;
|
||||
2. measure each component after UTF-8 encoding;
|
||||
3. accept 255 bytes without this warning and warn at 256 bytes or more;
|
||||
4. identify every over-limit file or folder name in the active-file status;
|
||||
5. do not reject, truncate, or rename the path; and
|
||||
6. treat the result of the real storage operation as authoritative.
|
||||
|
||||
If a scan cannot process an individual file, its path is recorded in the
|
||||
verbose log and remains eligible for a later retry. Ordinary start-up may still
|
||||
become ready so that unaffected files can synchronise. Explicit Fetch and
|
||||
Rebuild operations retain strict scan completion because they establish an
|
||||
authoritative local or remote state.
|
||||
|
||||
This policy does not replace the existing checks for reserved characters,
|
||||
case collisions, ignore rules, or configured file-size limits.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
### A–R
|
||||
|
||||
- **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.
|
||||
|
||||
### S–Z
|
||||
|
||||
- **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.
|
||||
@@ -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 |
|
||||
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
|
||||
|
||||
+19
-1
@@ -11,6 +11,24 @@
|
||||
|
||||
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.
|
||||
- [Path component length compatibility](design_docs/path_component_length_compatibility.md)
|
||||
explains why 255 UTF-8 bytes is an Android and Linux compatibility warning,
|
||||
rather than a universal rule for deciding whether a path is valid.
|
||||
- [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.
|
||||
|
||||

|
||||

|
||||
|
||||
+9
-94
@@ -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
@@ -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",
|
||||
|
||||
Generated
+13
-13
@@ -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",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.21",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.22",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -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"
|
||||
},
|
||||
@@ -4620,9 +4620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz",
|
||||
"integrity": "sha512-AGuZ3eqBP37HJXEkTSpJ5M5bvTx2lYNq+6Q5NuCPZeGdbv7g6cujGvccVR5ozGfKdHGSyFZND5x1oFS9crRhUg==",
|
||||
"version": "0.1.22",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.22.tgz",
|
||||
"integrity": "sha512-8TsFo6xgEO/uZzkQ4TE3yydUyK8pCbuMm0C4DC/8KhG8z06N6hQQmwR7bV+a3Zgt9A5tXPImTFJWTMUIxJYV2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+3
-3
@@ -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",
|
||||
@@ -177,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.21",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.22",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -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
-11
@@ -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[];
|
||||
|
||||
@@ -95,9 +91,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 +154,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 +284,7 @@ export class LiveSyncBaseCore<
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useConflictResolutionFeature(this);
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
|
||||
@@ -82,9 +82,11 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
# package.json lists only the packages that the CLI requires
|
||||
COPY src/apps/cli/package.json ./package.json
|
||||
RUN npm install --omit=dev
|
||||
# Remove build-only dependencies before resolving the standalone runtime tree.
|
||||
# npm --omit=dev omits them from disk, but still resolves their peer graph.
|
||||
COPY src/apps/cli/package.json ./package.json
|
||||
RUN npm pkg delete devDependencies \
|
||||
&& npm install --omit=dev
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 3 — runtime
|
||||
|
||||
+44
-32
@@ -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`).
|
||||
|
||||
@@ -15,7 +15,10 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
performFullScan,
|
||||
VaultScanResults,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -529,7 +532,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
writeStderrLine(standardIo, "[Command] mirror");
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
return await performFullScan(core, log, errorManager, false, true);
|
||||
return (await performFullScan(core, log, errorManager, false, true)) === VaultScanResults.COMPLETED;
|
||||
}
|
||||
|
||||
if (options.command === "remote-add") {
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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}'?",
|
||||
|
||||
@@ -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.":
|
||||
{
|
||||
@@ -740,6 +733,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 +990,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 +1095,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 +1442,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 +2044,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 +2543,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})",
|
||||
@@ -4195,6 +4212,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "等待就绪...",
|
||||
"zh-tw": "正在等待就緒⋯",
|
||||
},
|
||||
"moduleLog.pathComponentTooLong": {
|
||||
def: "This path contains a file or folder name longer than ${maxBytes} UTF-8 bytes. It may not work on some Android and Linux file systems.",
|
||||
},
|
||||
"moduleLog.showLog": {
|
||||
def: "Show Log",
|
||||
es: "Mostrar registro",
|
||||
@@ -5369,7 +5389,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 +6001,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 +6012,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 +6320,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 +6331,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 +6660,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 +8404,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 +8419,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 +9443,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 +9806,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 +10035,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 +10066,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",
|
||||
@@ -10370,6 +10417,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "Use Remote Configuration",
|
||||
"zh-tw": "使用遠端設定",
|
||||
},
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed": {
|
||||
def: "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
|
||||
},
|
||||
"Ui.Common.Signal.Caution": {
|
||||
def: "CAUTION",
|
||||
es: "PRECAUCIÓN",
|
||||
@@ -10398,6 +10448,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "警告",
|
||||
"zh-tw": "警告",
|
||||
},
|
||||
"Ui.Common.SomeFilesCouldNotBeSynchronised": {
|
||||
def: "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
},
|
||||
"Ui.Settings.Advanced.LocalDatabaseTweak": {
|
||||
def: "Local Database Tweak",
|
||||
es: "Ajuste fino de la base de datos local",
|
||||
@@ -12684,6 +12737,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)",
|
||||
},
|
||||
|
||||
@@ -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,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",
|
||||
@@ -90,6 +90,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 +122,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 +136,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 +240,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 +308,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.",
|
||||
@@ -477,6 +483,7 @@
|
||||
"moduleLiveSyncMain.optionResumeAndRestart": "Resume and restart Obsidian",
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
|
||||
"moduleLog.pathComponentTooLong": "This path contains a file or folder name longer than ${maxBytes} UTF-8 bytes. It may not work on some Android and Linux file systems.",
|
||||
"moduleLog.showLog": "Show Log",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Check it later",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "I have fixed it, and do not ask again",
|
||||
@@ -910,7 +917,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 +1028,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 +1070,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 +1100,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",
|
||||
@@ -1130,10 +1143,12 @@
|
||||
"TweakMismatchResolve.Title.AutoAcceptCompatible": "Auto-Accept Available",
|
||||
"TweakMismatchResolve.Title.TweakResolving": "Configuration Mismatch Detected",
|
||||
"TweakMismatchResolve.Title.UseRemoteConfig": "Use Remote Configuration",
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed": "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
|
||||
"Ui.Common.Signal.Caution": "CAUTION",
|
||||
"Ui.Common.Signal.Danger": "DANGER",
|
||||
"Ui.Common.Signal.Notice": "NOTICE",
|
||||
"Ui.Common.Signal.Warning": "WARNING",
|
||||
"Ui.Common.SomeFilesCouldNotBeSynchronised": "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
"Ui.Settings.Advanced.LocalDatabaseTweak": "Local Database Tweak",
|
||||
"Ui.Settings.Advanced.MemoryCache": "Memory Cache",
|
||||
"Ui.Settings.Advanced.TransferTweak": "Transfer Tweak",
|
||||
@@ -1462,5 +1477,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)!"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -90,6 +90,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 +123,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 +137,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 +181,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 +241,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 +309,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 +599,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 +654,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 +683,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 +712,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 +909,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 +1061,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 +1103,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 +1132,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 +1485,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!)"
|
||||
}
|
||||
|
||||
@@ -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 がある場合は、それを使うと簡単かつ安全に設定できます。",
|
||||
|
||||
@@ -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": "↓: 로컬 덮어쓰기",
|
||||
|
||||
@@ -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 был создан скриптом установки сервера, его использование обеспечивает простую и безопасную настройку。",
|
||||
|
||||
@@ -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": "↓:覆寫本機",
|
||||
|
||||
@@ -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,使用它可以更简单且更安全地完成配置。",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -129,6 +129,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 +159,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 +169,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 +346,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 +409,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.
|
||||
@@ -724,6 +732,9 @@ moduleLiveSyncMain:
|
||||
moduleLocalDatabase:
|
||||
logWaitingForReady: Waiting for ready...
|
||||
moduleLog:
|
||||
pathComponentTooLong: >-
|
||||
This path contains a file or folder name longer than ${maxBytes} UTF-8
|
||||
bytes. It may not work on some Android and Linux file systems.
|
||||
showLog: Show Log
|
||||
moduleMigration:
|
||||
fix0256:
|
||||
@@ -1488,7 +1499,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 +1765,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 +1816,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 +1893,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 +2096,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"
|
||||
@@ -2104,6 +2129,8 @@ xxhash64 (Fastest): xxhash64 (Fastest)
|
||||
"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."
|
||||
Ui:
|
||||
Common:
|
||||
LocalDatabaseInitialisationFailed: Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.
|
||||
SomeFilesCouldNotBeSynchronised: Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.
|
||||
Signal:
|
||||
Caution: CAUTION
|
||||
Danger: DANGER
|
||||
@@ -2419,6 +2446,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
|
||||
|
||||
@@ -132,6 +132,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 +167,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 +178,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 +230,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 +351,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 +432,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 +1526,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 +1664,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 +1692,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 +1719,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 +1812,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 +1922,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 +1973,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 +2036,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 +2632,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 +2688,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
|
||||
|
||||
@@ -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": "いいえ、前に戻ります"
|
||||
|
||||
@@ -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": "↓: 로컬 덮어쓰기"
|
||||
|
||||
@@ -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": "Нет, верните меня назад"
|
||||
|
||||
@@ -1667,7 +1667,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": "不,返回上一步"
|
||||
|
||||
@@ -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": "不,返回上一步"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||
|
||||
export interface OversizedPathComponent {
|
||||
component: string;
|
||||
utf8Bytes: number;
|
||||
}
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Return path components which exceed the conservative Android/Linux
|
||||
* compatibility boundary.
|
||||
*
|
||||
* Obsidian paths use forward slashes. The limit applies to each file or
|
||||
* folder name, not to the combined Vault-relative path.
|
||||
*/
|
||||
export function findPathComponentsExceedingUtf8Limit(
|
||||
path: string,
|
||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
): OversizedPathComponent[] {
|
||||
return path
|
||||
.split("/")
|
||||
.filter((component) => component.length > 0)
|
||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
|
||||
findPathComponentsExceedingUtf8Limit,
|
||||
} from "./pathCompatibility.ts";
|
||||
|
||||
describe("findPathComponentsExceedingUtf8Limit", () => {
|
||||
it("accepts 255 UTF-8 bytes and reports 256 UTF-8 bytes", () => {
|
||||
expect(findPathComponentsExceedingUtf8Limit("a".repeat(255))).toEqual([]);
|
||||
expect(findPathComponentsExceedingUtf8Limit("a".repeat(256))).toEqual([
|
||||
{
|
||||
component: "a".repeat(256),
|
||||
utf8Bytes: 256,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts UTF-8 bytes rather than JavaScript characters", () => {
|
||||
expect(findPathComponentsExceedingUtf8Limit("界".repeat(85))).toEqual([]);
|
||||
expect(findPathComponentsExceedingUtf8Limit(`${"界".repeat(85)}a`)).toEqual([
|
||||
{
|
||||
component: `${"界".repeat(85)}a`,
|
||||
utf8Bytes: 256,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not apply the component limit to the whole path", () => {
|
||||
const path = `${"a".repeat(200)}/${"b".repeat(200)}`;
|
||||
|
||||
expect(new TextEncoder().encode(path).byteLength).toBeGreaterThan(
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
);
|
||||
expect(findPathComponentsExceedingUtf8Limit(path)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an oversized folder component as well as an oversized file name", () => {
|
||||
const folder = "界".repeat(86);
|
||||
const file = `${"b".repeat(256)}.md`;
|
||||
|
||||
expect(findPathComponentsExceedingUtf8Limit(`parent/${folder}/${file}`)).toEqual([
|
||||
{ component: folder, utf8Bytes: 258 },
|
||||
{ component: file, utf8Bytes: 259 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,23 @@ describe("LiveSync-owned translation catalogue", () => {
|
||||
expect($msg("moduleCheckRemoteSize.optionIncreaseLimit", { newMax: "800" }, "def")).toBe("increase to 800MB");
|
||||
});
|
||||
|
||||
it("keeps the active-file path compatibility warning concise", () => {
|
||||
const oversizedComponent = `${"界".repeat(86)} (258 bytes)`;
|
||||
|
||||
expect(
|
||||
$msg(
|
||||
"moduleLog.pathComponentTooLong",
|
||||
{
|
||||
maxBytes: "255",
|
||||
components: oversizedComponent,
|
||||
},
|
||||
"def"
|
||||
)
|
||||
).toBe(
|
||||
"This path contains a file or folder name longer than 255 UTF-8 bytes. It may not work on some Android and Linux file systems."
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Commonlib's canonical English when the application catalogue has no translation", () => {
|
||||
setLang("es");
|
||||
|
||||
|
||||
+16
-9
@@ -6,7 +6,6 @@ import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
// import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
|
||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
@@ -26,10 +25,8 @@ import type { ServiceModules } from "./types.ts";
|
||||
import { setNoticeClass } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/wrapper";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
import { LiveSyncBaseCore } from "./LiveSyncBaseCore.ts";
|
||||
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
|
||||
import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts";
|
||||
import { SetupManager } from "./modules/features/SetupManager.ts";
|
||||
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
|
||||
import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts";
|
||||
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
@@ -38,7 +35,10 @@ import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
|
||||
import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts";
|
||||
import { useSetupQRCodeFeature } from "@/serviceFeatures/setupObsidian/qrCode";
|
||||
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
|
||||
import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
showOnboardingInvitation,
|
||||
useSetupManagerHandlersFeature,
|
||||
} from "./serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import { useP2PReplicatorCommands, useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||
import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
|
||||
@@ -46,6 +46,10 @@ import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync
|
||||
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
|
||||
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
|
||||
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
|
||||
import { useInteractiveConflictResolutionFeature } from "./serviceFeatures/interactiveConflictResolution";
|
||||
import { ConflictResolveModal } from "./modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { useObsidianReplicationRibbonFeature } from "./serviceFeatures/obsidianReplicationRibbon.ts";
|
||||
import { useStartupLifecycleFeature } from "./serviceFeatures/startupLifecycle";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -145,7 +149,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
setNoticeClass(Notice);
|
||||
|
||||
const serviceHub = new ObsidianServiceHub(this);
|
||||
let waitForCompatibilityReview = (): Promise<void> => Promise.resolve();
|
||||
|
||||
this.core = new LiveSyncBaseCore(
|
||||
serviceHub,
|
||||
@@ -156,15 +159,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const extraModules = [
|
||||
new ModuleObsidianEvents(this, core),
|
||||
new ModuleObsidianSettingDialogue(this, core),
|
||||
new ModuleObsidianMenu(core),
|
||||
new ModuleObsidianSettingsAsMarkdown(core),
|
||||
new ModuleLog(this, core),
|
||||
new ModuleObsidianDocumentHistory(this, core),
|
||||
new ModuleInteractiveConflictResolver(this, core),
|
||||
new ModuleObsidianGlobalHistory(this, core),
|
||||
// new ModuleDev(this, core),
|
||||
new SetupManager(core), // this should be moved to core?
|
||||
new ModuleMigration(core, () => waitForCompatibilityReview()),
|
||||
];
|
||||
return extraModules;
|
||||
},
|
||||
@@ -187,6 +187,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
setupManager.registerP2PSetupConnectionProbe(replicator.connectionProbe);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator, createInteractiveP2PReplication(replicator));
|
||||
useObsidianReplicationRibbonFeature(core);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -196,11 +197,17 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
useOfflineScanner(core);
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useInteractiveConflictResolutionFeature(core, (filename, conflictCheckResult) => {
|
||||
return new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
});
|
||||
const compatibilityReview = useCompatibilityReview(
|
||||
core,
|
||||
createObsidianCompatibilityReviewUi(core.confirm)
|
||||
);
|
||||
waitForCompatibilityReview = () => compatibilityReview.openReview();
|
||||
useStartupLifecycleFeature(core, {
|
||||
inviteToOnboarding: () => showOnboardingInvitation(core, setupManager),
|
||||
waitForCompatibilityReview: () => compatibilityReview.openReview(),
|
||||
});
|
||||
useReviewHarness(core, this, compatibilityReview);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { LOG_LEVEL_NOTICE, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
import { sendValue } from "octagonal-wheels/messagepassing/signal";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
|
||||
export class ModuleConflictChecker extends AbstractModule {
|
||||
async _queueConflictCheckIfOpen(file: FilePathWithPrefix): Promise<void> {
|
||||
const path = file;
|
||||
if (this.settings.checkConflictOnlyOnOpen) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != path) {
|
||||
this._log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.services.conflict.queueCheckFor(path);
|
||||
}
|
||||
|
||||
async _queueConflictCheck(file: FilePathWithPrefix): Promise<void> {
|
||||
const optionalConflictResult = await this.services.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await this.services.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
this.conflictCheckQueue.enqueue(file);
|
||||
}
|
||||
}
|
||||
|
||||
_waitForAllConflictProcessed(): Promise<boolean> {
|
||||
return this.conflictResolveQueue.waitForAllProcessed();
|
||||
}
|
||||
|
||||
// TODO-> Move to ModuleConflictResolver?
|
||||
conflictResolveQueue = new QueueProcessor(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await this.services.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// And, some cases, we do not need to synchronised. (e.g., auto-merge available).
|
||||
// Therefore, limiting global concurrency is performed on resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const filename = newEntity;
|
||||
sendValue("cancel-resolve-conflict:" + filename, true);
|
||||
const newQueue = [...queue].filter((e) => e != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
conflictCheckQueue = // First process - Check is the file actually need resolve -
|
||||
new QueueProcessor(
|
||||
(files: FilePathWithPrefix[]) => {
|
||||
const filename = files[0];
|
||||
return Promise.resolve([filename]);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
|
||||
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
|
||||
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleConflictResolver extends AbstractModule {
|
||||
private async _resolveConflictByDeletingRev(
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await this.core.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
this._log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
this._log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await this.core.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
this._log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
this._log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await this.core.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
this._log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
this._log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
|
||||
async checkConflictAndPerformAutoMerge(path: FilePathWithPrefix): Promise<diff_check_result> {
|
||||
//
|
||||
const ret = await this.localDatabase.tryAutoMerge(path, !this.settings.disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// Merged content is coming.
|
||||
// 1. Store the merged content to the storage
|
||||
if (!(await this.core.databaseFileAccess.storeContent(path, p))) {
|
||||
this._log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// should be one or more conflicts;
|
||||
if (leftLeaf == false) {
|
||||
// what's going on..
|
||||
this._log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = this.settings.resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// if (lMtime > rMtime) {
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await this.services.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
this._log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
}
|
||||
|
||||
private async _resolveConflict(filename: FilePathWithPrefix): Promise<void> {
|
||||
// const filename = filenames[0];
|
||||
return await serialized(`conflict-resolve:${filename}`, async () => {
|
||||
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// nothing to do.
|
||||
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
//auto resolved, but need check again;
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
//Wait for the running replication, if not running replication, run it once.
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
this._log("[conflict] Automatically merged, but we have to check it again");
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (this.settings.showMergeDialogOnlyOnActive) {
|
||||
const af = this.services.vault.getActiveFilePath();
|
||||
if (af && af != filename) {
|
||||
this._log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._log("[conflict] Manual merge required!");
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
});
|
||||
}
|
||||
|
||||
private async _anyResolveConflictByNewest(filename: FilePathWithPrefix, showNotice = true): Promise<boolean> {
|
||||
const currentRev = await this.core.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
this._log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await this.core.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await this.core.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
// console.warn(mTimeAndRev);
|
||||
this._log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
this._log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await this._resolveConflictByDeletingRev(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private async _resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this._anyResolveConflictByNewest(file, false);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
|
||||
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
|
||||
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
|
||||
this._resolveAllConflictedFilesByNewerOnes.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleConflictResolver } from "./ModuleConflictResolver";
|
||||
|
||||
function createModule(files: FilePathWithPrefix[] = []) {
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const tryAutoMerge = vi.fn();
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const resolveByUserInteraction = vi.fn(async () => false);
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
conflict: {
|
||||
resolveByNewest: vi.fn(async () => true),
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn(() => undefined),
|
||||
},
|
||||
},
|
||||
settings: DEFAULT_SETTINGS,
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: {
|
||||
tryAutoMerge,
|
||||
},
|
||||
storageAccess: {
|
||||
getFileNames: vi.fn(async () => files),
|
||||
},
|
||||
} as any;
|
||||
Object.defineProperty(core, "services", { get: () => core._services });
|
||||
|
||||
const module = new ModuleConflictResolver(core);
|
||||
module._log = vi.fn();
|
||||
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
|
||||
}
|
||||
|
||||
describe("ModuleConflictResolver bulk newest resolution", () => {
|
||||
it("retains the success notice for a non-bulk newest resolution", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("logs a successful bulk newest resolution without displaying a notice", async () => {
|
||||
const { module } = createModule();
|
||||
const path = "example.md" as FilePathWithPrefix;
|
||||
module.core.databaseFileAccess.fetchEntryMeta = vi.fn(
|
||||
async (_path: unknown, rev?: string): Promise<MetaEntry> =>
|
||||
({
|
||||
_id: "doc-id",
|
||||
_rev: rev ?? "2-current",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: rev ? 1 : 2,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
}) as unknown as MetaEntry
|
||||
);
|
||||
module.core.databaseFileAccess.getConflictedRevs = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(["1-old"])
|
||||
.mockResolvedValue([]);
|
||||
|
||||
await (module as any)._anyResolveConflictByNewest(path, false);
|
||||
|
||||
expect(module._log).toHaveBeenLastCalledWith(`${path} has been merged automatically`, LOG_LEVEL_INFO);
|
||||
});
|
||||
|
||||
it("updates notice-level progress once every ten checked files", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const { module } = createModule(files);
|
||||
const resolveByNewest = vi.spyOn(module as any, "_anyResolveConflictByNewest").mockResolvedValue(true);
|
||||
|
||||
await (module as any)._resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(resolveByNewest).toHaveBeenCalledTimes(11);
|
||||
expect(resolveByNewest).toHaveBeenCalledWith(files[0], false);
|
||||
expect(module._log).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(module._log).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver independent same-path creation", () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number) {
|
||||
return {
|
||||
rev,
|
||||
data,
|
||||
mtime,
|
||||
ctime: mtime,
|
||||
deleted: false,
|
||||
} as any;
|
||||
}
|
||||
|
||||
it("collapses one duplicate revision when independently created files have identical content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
});
|
||||
|
||||
it("returns a manual diff when independently created files have different content", async () => {
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleConflictResolver sensible merge hand-off", () => {
|
||||
it("keeps an unreadable non-winner revision unresolved", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: {
|
||||
rev: "3-current",
|
||||
data: "Readable current body\n",
|
||||
ctime: 1,
|
||||
mtime: 3,
|
||||
deleted: false,
|
||||
},
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the merged body and removes the resolved conflict leaf", async () => {
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
|
||||
tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await module.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
|
||||
});
|
||||
|
||||
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
|
||||
const path = "three-versions.md" as FilePathWithPrefix;
|
||||
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
|
||||
createModule();
|
||||
const remainingManualPair = {
|
||||
leftRev: "3-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
|
||||
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
|
||||
};
|
||||
tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
result: "Merged\n",
|
||||
conflictedRev: "2-second",
|
||||
})
|
||||
.mockResolvedValueOnce(remainingManualPair);
|
||||
|
||||
await (module as any)._resolveConflict(path);
|
||||
|
||||
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
|
||||
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
|
||||
await (module as any)._resolveConflict(path);
|
||||
|
||||
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({
|
||||
left: remainingManualPair.leftLeaf,
|
||||
right: remainingManualPair.rightLeaf,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
|
||||
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
|
||||
export class ModuleBasicMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = this.services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
if (this.settings.liveSync) {
|
||||
this.settings.liveSync = false;
|
||||
this._log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.settings.liveSync = true;
|
||||
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (this.services.appLifecycle.isSuspended()) {
|
||||
this.services.appLifecycle.setSuspended(false);
|
||||
this._log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
this.services.appLifecycle.setSuspended(true);
|
||||
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
// TODO, Replicator is possibly one of features. It should be moved to features.
|
||||
this.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!this.settings.useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => this.services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Command } from "@/deps";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleBasicMenu } from "./ModuleBasicMenu";
|
||||
|
||||
type RegisteredCommand = Command & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
enableDebugTools: false,
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | null => "note.md"),
|
||||
scanVault: vi.fn(async () => undefined),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
appLifecycle: {
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
settings,
|
||||
_services: services,
|
||||
services,
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
localDatabase: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
},
|
||||
getDBEntryMeta: vi.fn(async () => ({
|
||||
_id: "f:note",
|
||||
_rev: "2-current",
|
||||
_conflicts: [],
|
||||
path: "note.md",
|
||||
ctime: 100,
|
||||
mtime: 200,
|
||||
size: 12,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
data: "",
|
||||
children: ["h:private-chunk-id"],
|
||||
eden: {},
|
||||
})),
|
||||
allDocsRaw: vi.fn(async () => ({
|
||||
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
|
||||
})),
|
||||
},
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
|
||||
},
|
||||
replicator: {
|
||||
terminateSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
const module = new ModuleBasicMenu(core as never);
|
||||
|
||||
return {
|
||||
commands,
|
||||
core,
|
||||
module,
|
||||
services,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleBasicMenu command palette", () => {
|
||||
it("uses clear user-facing names without changing the established command IDs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
});
|
||||
|
||||
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps maintenance commands out of the normal palette", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("routes an explicit stop through the active provider capability", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(false)).toBe(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(fixture.core.replicator.terminateSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
const command = fixture.getCommand("livesync-dump");
|
||||
expect(command.name).toBe("Copy database information for the active file");
|
||||
expect(command.checkCallback?.(true)).toBe(true);
|
||||
|
||||
command.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
|
||||
});
|
||||
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
|
||||
expect(title).toBe("Database information for note.md");
|
||||
expect(report).toContain("note.md");
|
||||
expect(report).toContain("2-current");
|
||||
expect(report).toContain("h:private-chunk-id");
|
||||
expect(report).toContain("1-chunk");
|
||||
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the active-file database report when no file is active", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
|
||||
|
||||
await fixture.module._everyOnloadStart();
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { EVENT_REQUEST_RUN_DOCTOR, EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub } from "@/common/events.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
constructor(
|
||||
core: LiveSyncCore,
|
||||
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: this.core.confirm,
|
||||
translate: this.services.context.translate,
|
||||
},
|
||||
this.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
this.settings = settings;
|
||||
await this.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async migrateDisableBulkSend() {
|
||||
if (disableLegacyBulkChunkPreSend(this.settings)) {
|
||||
this._log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
showOnboardingInvitation(this.core, manager);
|
||||
}
|
||||
|
||||
async hasIncompleteDocs(force: boolean = false): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await this.core.kvDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
this._log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = this.core.services.context.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = this.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await this.services.vault.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
|
||||
if (!doc || !isLoadedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
if (isDeletedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
if (
|
||||
recordedSize !== actualSize ||
|
||||
sizeOnStorage !== actualSize ||
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
recordedSize,
|
||||
actualSize,
|
||||
storageSize: sizeOnStorage,
|
||||
contentMatched,
|
||||
isConflicted,
|
||||
});
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
Logger("No size mismatches found", LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
const recoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize === e.storageSize && !e.isConflicted;
|
||||
});
|
||||
const unrecoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize !== e.storageSize || e.isConflicted;
|
||||
});
|
||||
const fileInfo = (e: (typeof errorFiles)[0]) => {
|
||||
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
|
||||
};
|
||||
const messageUnrecoverable =
|
||||
unrecoverable.length > 0
|
||||
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
|
||||
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
})
|
||||
: "";
|
||||
|
||||
const message = $msg("moduleMigration.fix0256.message", {
|
||||
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
messageUnrecoverable,
|
||||
});
|
||||
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
|
||||
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
|
||||
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
|
||||
title: $msg("moduleMigration.fix0256.title"),
|
||||
defaultAction: CHECK_IT_LATER,
|
||||
});
|
||||
if (ret == FIX) {
|
||||
for (const file of recoverable) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await this.core.storageAccess.getFileStub(file.path);
|
||||
if (stubFile == null) {
|
||||
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
|
||||
stubFile.stat.mtime = Date.now();
|
||||
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
|
||||
if (result) {
|
||||
Logger(`Successfully restored ${file.path} from storage`);
|
||||
} else {
|
||||
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
} else if (ret === DISMISS) {
|
||||
// User chose to dismiss the issue
|
||||
await this.core.kvDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
|
||||
async hasCompromisedChunks(): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!this.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === 0 && localCompromised === 0) {
|
||||
return true;
|
||||
}
|
||||
Logger(
|
||||
`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const title = $msg("moduleMigration.insecureChunkExist.title");
|
||||
const msg = $msg("moduleMigration.insecureChunkExist.message");
|
||||
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
|
||||
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
|
||||
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
|
||||
const buttons = [REBUILD, FETCH, DISMISS];
|
||||
if (remoteCompromised != 0) {
|
||||
buttons.splice(buttons.indexOf(FETCH), 1);
|
||||
}
|
||||
const result = await this.core.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
this._log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
return await runConfiguredStartupLifecycle({
|
||||
databaseReady: this.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () => this.hasCompromisedChunks(),
|
||||
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
|
||||
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
|
||||
runDoctor: () => this.migrateUsingDoctor(false),
|
||||
migrateBulkSend: () => this.migrateDisableBulkSend(),
|
||||
});
|
||||
}
|
||||
_everyOnLayoutReady(): Promise<boolean> {
|
||||
const shouldInitialiseDatabase = runStartupEntryLifecycle({
|
||||
configured: this.settings.isConfigured === true,
|
||||
inviteToOnboarding: () => this.initialMessage(),
|
||||
});
|
||||
if (!shouldInitialiseDatabase) return Promise.resolve(false);
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE, async () => {
|
||||
await this.hasIncompleteDocs(true);
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/features/SetupManager.ts", () => ({
|
||||
SetupManager: class SetupManager {},
|
||||
}));
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
import { ModuleMigration } from "./ModuleMigration.ts";
|
||||
|
||||
async function* noDocuments() {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan() {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function createMigration(
|
||||
findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments,
|
||||
settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 1 }
|
||||
) {
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(() => true),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
context: { noticeGroups },
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
vault: { isTargetFile: vi.fn(async () => true) },
|
||||
path: { getPath: vi.fn() },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
kvDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
localDatabase: { findAllNormalDocs },
|
||||
storageAccess: {},
|
||||
settings,
|
||||
};
|
||||
return {
|
||||
migration: new ModuleMigration(core as never),
|
||||
noticeGroups,
|
||||
saveSettingData: services.setting.saveSettingData,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ModuleMigration obsolete-setting migration", () => {
|
||||
it("persists the removal of an enabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(saveSettingData).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled automatic bulk chunk pre-send setting", async () => {
|
||||
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 16 };
|
||||
const { migration, saveSettingData } = createMigration(noDocuments, settings);
|
||||
|
||||
await migration.migrateDisableBulkSend();
|
||||
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleMigration incomplete-document notice", () => {
|
||||
it("keeps the check and its result in one persistent named group", async () => {
|
||||
const { migration, noticeGroups } = createMigration();
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { migration, noticeGroups } = createMigration(failedDocumentScan);
|
||||
|
||||
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
|
||||
|
||||
describe("ModuleObsidianMenu ribbon", () => {
|
||||
it("retains visible progress and full interaction authority", async () => {
|
||||
let runRibbonAction: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
runRibbonAction = callback;
|
||||
return { addClass };
|
||||
}),
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
};
|
||||
const module = new ModuleObsidianMenu({ _services: services, services } as never);
|
||||
|
||||
await module._everyOnloadStart();
|
||||
await runRibbonAction?.();
|
||||
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { POSTPONED, type MergeDialogResult } from "@/serviceFeatures/interactiveConflictResolution/types";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
export { POSTPONED, type MergeDialogResult };
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
@@ -35,7 +34,7 @@ export class ConflictResolveModal extends Modal {
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
private eventSubscriptions?: AbortController;
|
||||
currentDiffIndex = -1;
|
||||
diffView!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
@@ -112,20 +111,31 @@ export class ConflictResolveModal extends Modal {
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.eventSubscriptions?.abort();
|
||||
const eventSubscriptions = new AbortController();
|
||||
this.eventSubscriptions = eventSubscriptions;
|
||||
eventHub.onceEvent(
|
||||
EVENT_PLUGIN_UNLOADED,
|
||||
() => {
|
||||
this.sendResponse(CANCELLED);
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
);
|
||||
if (!this.readOnly) {
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
eventHub.onEvent(
|
||||
EVENT_CONFLICT_CANCELLED,
|
||||
(path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
);
|
||||
}
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
@@ -216,9 +226,8 @@ export class ConflictResolveModal extends Modal {
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.eventSubscriptions?.abort();
|
||||
this.eventSubscriptions = undefined;
|
||||
if (this.consumed) {
|
||||
return;
|
||||
}
|
||||
|
||||
+43
-22
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
|
||||
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED, eventHub } from "@/common/events.ts";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
@@ -24,12 +25,7 @@ vi.mock("@/deps.ts", () => ({
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
if (
|
||||
_tag === "button" &&
|
||||
typeof _options === "object" &&
|
||||
_options !== null &&
|
||||
"text" in _options
|
||||
) {
|
||||
if (_tag === "button" && typeof _options === "object" && _options !== null && "text" in _options) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
@@ -93,23 +89,50 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
it("closes for an external resolution of the same file and ignores other files", async () => {
|
||||
const filename = "resolved-elsewhere.md" as FilePathWithPrefix;
|
||||
const modal = new ConflictResolveModal({} as never, filename, conflict);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, "other.md" as FilePathWithPrefix);
|
||||
const stateAfterOtherFile = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
|
||||
await expect(modal.waitForResult()).resolves.toBe(CANCELLED);
|
||||
expect(stateAfterOtherFile).toBe("still-open");
|
||||
});
|
||||
|
||||
it("closes and completes its result when the plug-in unloads", async () => {
|
||||
const modal = new ConflictResolveModal(
|
||||
{} as never,
|
||||
"open-during-unload.md" as FilePathWithPrefix,
|
||||
conflict
|
||||
);
|
||||
modal.onOpen();
|
||||
|
||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
const result = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 25)),
|
||||
]);
|
||||
modal.sendResponse(CANCELLED);
|
||||
|
||||
expect(result).toBe(CANCELLED);
|
||||
});
|
||||
|
||||
it("renders a read-only comparison with no resolution actions", () => {
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal & { createdButtons: string[] };
|
||||
const modal = new ReadOnlyModal(
|
||||
{},
|
||||
"repair-preview.md",
|
||||
conflict,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
}
|
||||
);
|
||||
const modal = new ReadOnlyModal({}, "repair-preview.md", conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
});
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
@@ -124,9 +147,7 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
|
||||
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal;
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (...args: unknown[]) => ConflictResolveModal;
|
||||
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { displayRev } from "@/common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
|
||||
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
|
||||
try {
|
||||
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveConflictMessages(): Promise<string[]> {
|
||||
const filename = this.services.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await this.getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
|
||||
if ((await this.getConflictVersionCount(filename)) === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
|
||||
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
callback: async () => {
|
||||
await this.pickFileForResolve();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await this.allConflictCheck();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
|
||||
// UI for resolving conflicts should one-by-one.
|
||||
return await serialized(`conflict-resolve-ui`, async () => {
|
||||
if (this.postponedConflictEpisodes.has(filename)) {
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
dialog.open();
|
||||
const selected = await dialog.waitForResult();
|
||||
if (selected === POSTPONED) {
|
||||
this.postponedConflictEpisodes.add(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
const testDoc = await this.localDatabase.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (testDoc === false) {
|
||||
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await this.refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
this._log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.refreshConflictState(filename);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
|
||||
if (toDelete === LEAVE_TO_SUBSEQUENT) {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
|
||||
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (this.settings.syncAfterMerge && !this.services.appLifecycle.isSuspended()) {
|
||||
await this.services.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
async allConflictCheck() {
|
||||
let notifyIfEmpty = true;
|
||||
while (await this.pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
}
|
||||
|
||||
async pickFileForResolve(notifyIfEmpty = true) {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
path: this.getPath(doc),
|
||||
dispPath: this.getPathWithoutPrefix(doc),
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
if (notifyIfEmpty) {
|
||||
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await this.requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async _allScanStat(): Promise<boolean> {
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
this._log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: this.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
this.core.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => this.allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
this._log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
for (const note of notes) {
|
||||
this._log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (e) {
|
||||
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
|
||||
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
|
||||
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
|
||||
fireAndForget(() => this.refreshConflictState(filename));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
constructed: 0,
|
||||
result: undefined as unknown,
|
||||
postponed: Symbol("postponed"),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
displayRev: (revision: string) => revision,
|
||||
}));
|
||||
|
||||
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
POSTPONED: modalState.postponed,
|
||||
ConflictResolveModal: class ConflictResolveModal {
|
||||
constructor() {
|
||||
modalState.constructed++;
|
||||
}
|
||||
|
||||
open() {}
|
||||
|
||||
async waitForResult() {
|
||||
return modalState.result;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createModule(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const handlers = {
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: { addHandler: vi.fn() },
|
||||
onInitialise: { addHandler: vi.fn() },
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: { addHandler: vi.fn() },
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => conflictedRevisions),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
|
||||
},
|
||||
};
|
||||
const plugin = { app: {} };
|
||||
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
|
||||
module._log = vi.fn();
|
||||
return { core, handlers, module, services };
|
||||
}
|
||||
|
||||
describe("ModuleInteractiveConflictResolver postponement", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { module } = createModule();
|
||||
modalState.result = CANCELLED;
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { module, services } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await (module as any).requestConflictResolution(path);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { module } = createModule(conflictedRevisions);
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await (module as any).refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { core, handlers, module, services } = createModule();
|
||||
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createModule(conflictedRevisions);
|
||||
|
||||
await firstSession.module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createModule(conflictedRevisions);
|
||||
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
|
||||
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.module._anyResolveConflictByUI(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
|
||||
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
|
||||
modalState.result = LEAVE_TO_SUBSEQUENT;
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
|
||||
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const { core, module, services } = createModule(["2-other"]);
|
||||
modalState.result = "2-right";
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleInteractiveConflictResolver file selection", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const { core, module } = createModule();
|
||||
core.localDatabase.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
core.confirm.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await module.allConflictCheck();
|
||||
|
||||
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module.pickFileForResolve();
|
||||
|
||||
expect(module._log).toHaveBeenCalledTimes(1);
|
||||
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-com
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
import {
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
|
||||
findPathComponentsExceedingUtf8Limit,
|
||||
} from "@/common/pathCompatibility.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -293,6 +297,14 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
reasonWarn.push("Some platforms may be unable to process this file correctly: " + labels.join(" "));
|
||||
}
|
||||
}
|
||||
const oversizedPathComponents = findPathComponentsExceedingUtf8Limit(thisFile.path);
|
||||
if (oversizedPathComponents.length > 0) {
|
||||
reasonWarn.push(
|
||||
$msg("moduleLog.pathComponentTooLong", {
|
||||
maxBytes: `${ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY}`,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Case Sensitivity
|
||||
if (this.services.vault.shouldCheckCaseInsensitively()) {
|
||||
const f = (await this.core.storageAccess.getFiles())
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
MetadataDocumentRepairResults,
|
||||
OfflineScanUnresolvedReasons,
|
||||
repairMetadataDocumentIdentity,
|
||||
VaultScanResults,
|
||||
type MetadataDocumentIdentityIssue,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
@@ -74,7 +75,7 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
.setDisabled(false)
|
||||
.onClick(() => {
|
||||
this.closeSetting();
|
||||
eventHub.emitEvent(EVENT_REQUEST_RUN_DOCTOR, "you wanted(Thank you)!");
|
||||
eventHub.emitEvent(EVENT_REQUEST_RUN_DOCTOR, $msg("you wanted(Thank you)!"));
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
@@ -292,7 +293,8 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
)) === repairAction,
|
||||
repair: async (repairRequest) =>
|
||||
await repairMetadataDocumentIdentity(this.core, repairRequest),
|
||||
requestOrdinaryScan: async () => await this.services.vault.scanVault(true, false),
|
||||
requestOrdinaryScan: async () =>
|
||||
(await this.services.vault.scanVault(true, false)) === VaultScanResults.COMPLETED,
|
||||
});
|
||||
|
||||
if (execution.status === MetadataIdentityRepairExecutions.CANCELLED) return;
|
||||
|
||||
@@ -412,7 +412,9 @@ export function paneMaintenance(
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.services.database.resetDatabase();
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
if (!(await this.services.databaseEvents.initialiseDatabase())) {
|
||||
Logger($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
describe("paneMaintenance", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
const resetRemoteBucket = vi.fn(async () => false);
|
||||
@@ -140,4 +140,49 @@ describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
);
|
||||
expect(maintenanceHarness.logger).not.toHaveBeenCalledWith("Deleted all data on remote server", "notice");
|
||||
});
|
||||
|
||||
it("reports when database initialisation after a local reset does not complete", async () => {
|
||||
const resetDatabase = vi.fn(async () => undefined);
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Reset") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
const host = {
|
||||
core: {},
|
||||
createEl: vi.fn(),
|
||||
editingSettings: {},
|
||||
isConfiguredAs: vi.fn(),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO: vi.fn(),
|
||||
services: {
|
||||
appLifecycle: { askRestart: vi.fn() },
|
||||
database: { resetDatabase },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
setting: { saveSettingData: vi.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
const deleteLocalDatabase = maintenanceHarness.createdSettings.find(
|
||||
({ name }) => name === "Delete local database to reset or uninstall Self-hosted LiveSync"
|
||||
);
|
||||
if (!deleteLocalDatabase?.click) {
|
||||
throw new Error("Delete local database action was not registered");
|
||||
}
|
||||
|
||||
await deleteLocalDatabase.click();
|
||||
|
||||
expect(resetDatabase).toHaveBeenCalledOnce();
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
"notice"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-brows
|
||||
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { migrateDatabases } from "./settingUtils.ts";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, "Compatibility (Metadata)").then((paneEl) => {
|
||||
@@ -142,7 +143,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
|
||||
this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => {
|
||||
Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE);
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
if (!(await this.services.databaseEvents.initialiseDatabase())) {
|
||||
Logger($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(paneEl).autoWireDropDown("hashAlg", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { panePatches } from "./PanePatches.ts";
|
||||
|
||||
const remediationHarness = vi.hoisted(() => {
|
||||
@@ -14,11 +15,13 @@ const remediationHarness = vi.hoisted(() => {
|
||||
};
|
||||
const setButtonClassState = vi.fn();
|
||||
const setSettingClassState = vi.fn();
|
||||
const logger = vi.fn();
|
||||
|
||||
return {
|
||||
createSpan,
|
||||
dateElement,
|
||||
inputEl,
|
||||
logger,
|
||||
setButtonClassState,
|
||||
setSettingClassState,
|
||||
textComponent,
|
||||
@@ -59,9 +62,25 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
autoWireToggle(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireText(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireDropDown(): this {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({
|
||||
Logger: remediationHarness.logger,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, "activeDocument");
|
||||
vi.clearAllMocks();
|
||||
@@ -69,7 +88,7 @@ afterEach(() => {
|
||||
remediationHarness.inputEl.type = "";
|
||||
});
|
||||
|
||||
describe("panePatches remediation setting", () => {
|
||||
describe("panePatches", () => {
|
||||
it("creates the status element in the setting control instead of the document", () => {
|
||||
const hierarchyError = new DOMException(
|
||||
"Failed to execute 'appendChild' on 'Node': Only one element on document allowed.",
|
||||
@@ -115,4 +134,36 @@ describe("panePatches remediation setting", () => {
|
||||
);
|
||||
expect(remediationHarness.setButtonClassState).toHaveBeenCalledWith("sls-setting-additional-action", true);
|
||||
});
|
||||
|
||||
it("reports when database reinitialisation after a suffix change does not complete", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
let onSuffixSaved: (() => Promise<void>) | undefined;
|
||||
const host = {
|
||||
addOnSaved: vi.fn((key: string, callback: () => Promise<void>) => {
|
||||
if (key === "additionalSuffixOfDatabaseName") onSuffixSaved = callback;
|
||||
}),
|
||||
services: {
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
};
|
||||
const addPanel = vi.fn((_paneEl: HTMLElement, title: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (title === "Edge case addressing (Database)") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
panePatches.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
if (!onSuffixSaved) throw new Error("Database suffix save handler was not registered");
|
||||
|
||||
await onSuffixSaved();
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(remediationHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage(
|
||||
"(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."
|
||||
)}
|
||||
</Option>
|
||||
</Options>
|
||||
|
||||
@@ -48,80 +48,94 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title={translateMessage("End-to-End Encryption")} />
|
||||
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
|
||||
<InputRow label={translateMessage("End-to-End Encryption")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
<Password
|
||||
name="e2ee-passphrase"
|
||||
placeholder={translateMessage("Enter your passphrase")}
|
||||
bind:value={encryptionSettings.passphrase}
|
||||
disabled={!encryptionSettings.encrypt}
|
||||
required={encryptionSettings.encrypt}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote title={translateMessage("Strongly Recommended")}>
|
||||
{translateMessage(
|
||||
"Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices."
|
||||
)}
|
||||
<br />
|
||||
{translateMessage(
|
||||
"Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
{translateMessage("This setting must be the same even when connecting to multiple synchronisation destinations.")}
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("Obfuscate Properties")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={encryptionSettings.usePathObfuscation}
|
||||
disabled={!encryptionSettings.encrypt}
|
||||
/>
|
||||
</InputRow>
|
||||
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<InputRow label={translateMessage("Encryption Algorithm")}>
|
||||
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
|
||||
{#each Object.values(E2EEAlgorithms) as alg}
|
||||
<option value={alg}>{E2EEAlgorithmNames[alg] ?? alg}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="sls-e2ee-dialog">
|
||||
<DialogHeader title={translateMessage("End-to-End Encryption")} />
|
||||
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
|
||||
<InputRow label={translateMessage("End-to-End Encryption")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
<InfoNote title={translateMessage("Strongly Recommended")}>
|
||||
{translateMessage(
|
||||
"In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.",
|
||||
{ algorithm: E2EEAlgorithmNames[DEFAULT_SETTINGS.E2EEAlgorithm] }
|
||||
"Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices."
|
||||
)}
|
||||
<br />
|
||||
{translateMessage(
|
||||
"Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future."
|
||||
)}
|
||||
</InfoNote>
|
||||
{#if encryptionSettings.encrypt}
|
||||
<InputRow label={translateMessage("Passphrase")}>
|
||||
<Password
|
||||
name="e2ee-passphrase"
|
||||
placeholder={translateMessage("Enter your passphrase")}
|
||||
bind:value={encryptionSettings.passphrase}
|
||||
required
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"This setting must be the same even when connecting to multiple synchronisation destinations."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("Obfuscate Properties")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.usePathObfuscation} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<InputRow label={translateMessage("Encryption Algorithm")}>
|
||||
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
|
||||
{#each Object.values(E2EEAlgorithms) as alg}
|
||||
<option value={alg}>{E2EEAlgorithmNames[alg] ?? alg}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.",
|
||||
{ algorithm: E2EEAlgorithmNames[DEFAULT_SETTINGS.E2EEAlgorithm] }
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data."
|
||||
)}
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data."
|
||||
)}
|
||||
<p>
|
||||
{translateMessage(
|
||||
"Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{translateMessage(
|
||||
"Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted."
|
||||
)} <br /><br />
|
||||
{translateMessage("Please understand that this is intended behaviour.")}
|
||||
</p>
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote warning>
|
||||
<p>
|
||||
{translateMessage(
|
||||
"Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{translateMessage(
|
||||
"Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted."
|
||||
)} <br /><br />
|
||||
{translateMessage("Please understand that this is intended behaviour.")}
|
||||
</p>
|
||||
</InfoNote>
|
||||
<UserDecisions>
|
||||
<Decision title={translateMessage("Proceed")} important disabled={!e2eeValid} commit={() => commit()} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
</div>
|
||||
|
||||
<UserDecisions>
|
||||
<Decision title={translateMessage("Proceed")} important disabled={!e2eeValid} commit={() => commit()} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
<style>
|
||||
.sls-e2ee-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
:global(.dialog-host .sls-e2ee-dialog label > span) {
|
||||
width: auto;
|
||||
min-width: 8em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker";
|
||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
@@ -42,11 +43,17 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const isInitialized = await this.services.databaseEvents.initialiseDatabase(false, false);
|
||||
if (!isInitialized) {
|
||||
// Ordinary start-up may continue when individual files could not be
|
||||
// processed. Explicit Fetch and Rebuild flows retain the strict default.
|
||||
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(false, false, false, true);
|
||||
if (initialisationResult === VaultScanResults.FAILED) {
|
||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
//TODO:stop all sync.
|
||||
return false;
|
||||
}
|
||||
if (initialisationResult === VaultScanResults.COMPLETED_WITH_FILE_FAILURES) {
|
||||
this._log($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
if (!(await this.core.services.appLifecycle.onFirstInitialise())) return false;
|
||||
// await this.core.$$realizeSettingSyncMode();
|
||||
await this.services.control.applySettings();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_LAYOUT_READY: "layout-ready",
|
||||
EVENT_PLUGIN_LOADED: "plugin-loaded",
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB: "reload-setting-tab",
|
||||
EVENT_SETTING_SAVED: "setting-saved",
|
||||
eventHub: {
|
||||
emitEvent: vi.fn(),
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
setLang: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ModuleLiveSyncMain } from "./ModuleLiveSyncMain.ts";
|
||||
|
||||
describe("ModuleLiveSyncMain", () => {
|
||||
it("reports a database preparation failure at the application boundary", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const log = vi.fn();
|
||||
const host = {
|
||||
core: {
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: log,
|
||||
};
|
||||
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true);
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("warns when start-up continues with individual file failures", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => "completed-with-file-failures");
|
||||
const log = vi.fn();
|
||||
const appLifecycle = {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
onFirstInitialise: vi.fn(async () => true),
|
||||
onScanningStartupIssues: vi.fn(async () => true),
|
||||
};
|
||||
const host = {
|
||||
core: {
|
||||
services: { appLifecycle },
|
||||
},
|
||||
services: {
|
||||
appLifecycle,
|
||||
control: { applySettings: vi.fn(async () => undefined) },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: log,
|
||||
};
|
||||
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.SomeFilesCouldNotBeSynchronised", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
let showPassword = $state(false);
|
||||
const type = $derived.by(() => (showPassword ? "text" : "password"));
|
||||
const translatedPlaceholder = $derived.by(() => translate(placeholder));
|
||||
const toggleLabel = $derived.by(() => translate(showPassword ? "Hide password" : "Show password"));
|
||||
</script>
|
||||
|
||||
<input
|
||||
@@ -31,4 +32,36 @@
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
<input type="checkbox" bind:checked={showPassword} />
|
||||
<button
|
||||
type="button"
|
||||
class="sls-password-toggle"
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
aria-pressed={showPassword}
|
||||
{disabled}
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.sls-password-toggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
margin-left: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sls-password-toggle:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
:global(body.is-mobile) .sls-password-toggle {
|
||||
min-width: 44px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
Your {title || "data"} has been copied to the clipboard.
|
||||
</InfoNote>
|
||||
<UserDecisions>
|
||||
<Decision title="OK" important={true} {commit} />
|
||||
<Decision title={translateMessage("Ok")} important={true} {commit} />
|
||||
</UserDecisions>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { copyFileDatabaseInfo, type FileDatabaseInfoCore } from "@/serviceFeatures/fileDatabaseInfo";
|
||||
|
||||
/**
|
||||
* Services required by the platform-independent command palette actions.
|
||||
*
|
||||
* The database report deliberately receives a structural adapter rather than
|
||||
* the whole host. This keeps the report helper independent from the core while
|
||||
* retaining the same local database, storage, settings, path, and UI sources.
|
||||
*/
|
||||
export type BasicCommandsHost = NecessaryServices<
|
||||
| "API"
|
||||
| "appLifecycle"
|
||||
| "control"
|
||||
| "database"
|
||||
| "fileProcessing"
|
||||
| "path"
|
||||
| "replication"
|
||||
| "setting"
|
||||
| "UI"
|
||||
| "vault",
|
||||
"storageAccess"
|
||||
>;
|
||||
|
||||
function createFileDatabaseInfoCore(host: BasicCommandsHost): FileDatabaseInfoCore {
|
||||
const { services, serviceModules } = host;
|
||||
return {
|
||||
localDatabase: services.database.localDatabase,
|
||||
services: {
|
||||
path: services.path,
|
||||
UI: services.UI,
|
||||
},
|
||||
settings: services.setting.currentSettings(),
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the platform-independent command palette actions.
|
||||
*
|
||||
* Registration remains tied to `onInitialise`, matching the legacy module's
|
||||
* timing and allowing hosts to compose the feature before the lifecycle runs.
|
||||
*/
|
||||
export function useBasicCommandsFeature(host: BasicCommandsHost): void {
|
||||
const { services } = host;
|
||||
const log = createInstanceLogFunction("SF:BasicCommands", services.API);
|
||||
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "livesync-replicate",
|
||||
name: $msg("Sync now"),
|
||||
callback: async () => {
|
||||
await services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-dump",
|
||||
name: $msg("Copy database information for the active file"),
|
||||
checkCallback: (checking) => {
|
||||
const file = services.vault.getActiveFilePath();
|
||||
if (!file) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => copyFileDatabaseInfo(createFileDatabaseInfoCore(host), file));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-toggle",
|
||||
name: "Toggle LiveSync",
|
||||
callback: async () => {
|
||||
const settings = services.setting.currentSettings();
|
||||
if (settings.liveSync) {
|
||||
settings.liveSync = false;
|
||||
log("LiveSync Disabled.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
settings.liveSync = true;
|
||||
log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await services.control.applySettings();
|
||||
await services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-suspendall",
|
||||
name: "Toggle All Sync.",
|
||||
callback: async () => {
|
||||
if (services.appLifecycle.isSuspended()) {
|
||||
services.appLifecycle.setSuspended(false);
|
||||
log("Self-hosted LiveSync resumed", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
services.appLifecycle.setSuspended(true);
|
||||
log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await services.control.applySettings();
|
||||
await services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-scan-files",
|
||||
name: "Scan storage and database again",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => services.vault.scanVault(true));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-runbatch",
|
||||
name: $msg("Apply pending changes now"),
|
||||
callback: async () => {
|
||||
await services.fileProcessing.commitPendingFileEvents();
|
||||
},
|
||||
});
|
||||
|
||||
services.API.addCommand({
|
||||
id: "livesync-abortsync",
|
||||
name: "Abort synchronization immediately",
|
||||
checkCallback: (checking) => {
|
||||
if (!services.setting.currentSettings().useAdvancedMode) return false;
|
||||
if (!checking) {
|
||||
fireAndForget(() => services.replication.stopActiveTransfer());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { copyFileDatabaseInfo } from "./fileDatabaseInfo";
|
||||
import { useBasicCommandsFeature, type BasicCommandsHost } from "./basicCommands";
|
||||
|
||||
vi.mock("./fileDatabaseInfo", () => ({
|
||||
copyFileDatabaseInfo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
type RegisteredCommand = ICommandCompat & {
|
||||
checkCallback?: (checking: boolean) => boolean | void;
|
||||
};
|
||||
|
||||
function createFixture() {
|
||||
const commands: RegisteredCommand[] = [];
|
||||
const initialiseHandlers: Array<() => Promise<unknown>> = [];
|
||||
const settings = {
|
||||
liveSync: false,
|
||||
useAdvancedMode: false,
|
||||
};
|
||||
const api = {
|
||||
addCommand: vi.fn((command: RegisteredCommand) => {
|
||||
commands.push(command);
|
||||
return command;
|
||||
}),
|
||||
addLog: vi.fn(),
|
||||
};
|
||||
const services = {
|
||||
API: api,
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<unknown>) => {
|
||||
initialiseHandlers.push(handler);
|
||||
}),
|
||||
},
|
||||
isSuspended: vi.fn(() => false),
|
||||
setSuspended: vi.fn(),
|
||||
},
|
||||
control: {
|
||||
applySettings: vi.fn(async () => undefined),
|
||||
},
|
||||
database: {
|
||||
localDatabase: { databaseMarker: "local" },
|
||||
},
|
||||
fileProcessing: {
|
||||
commitPendingFileEvents: vi.fn(async () => true),
|
||||
},
|
||||
path: {
|
||||
path2id: vi.fn(async () => "f:note"),
|
||||
},
|
||||
replication: {
|
||||
replicateUserInitiated: vi.fn(async () => ({ status: "completed" as const })),
|
||||
stopActiveTransfer: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
setting: {
|
||||
currentSettings: vi.fn(() => settings),
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
UI: {
|
||||
promptCopyToClipboard: vi.fn(async () => true),
|
||||
},
|
||||
vault: {
|
||||
getActiveFilePath: vi.fn((): string | undefined => "note.md"),
|
||||
scanVault: vi.fn(async () => true),
|
||||
},
|
||||
};
|
||||
const serviceModules = {
|
||||
storageAccess: {
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
statHidden: vi.fn(async () => ({ ctime: 0, mtime: 0, size: 0, type: "file" })),
|
||||
},
|
||||
};
|
||||
const host = { services, serviceModules } as unknown as BasicCommandsHost;
|
||||
|
||||
return {
|
||||
api,
|
||||
commands,
|
||||
host,
|
||||
initialiseHandlers,
|
||||
services,
|
||||
serviceModules,
|
||||
settings,
|
||||
getCommand(id: string) {
|
||||
const command = commands.find((candidate) => candidate.id === id);
|
||||
expect(command, `command ${id}`).toBeDefined();
|
||||
return command!;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function initialise(fixture: ReturnType<typeof createFixture>) {
|
||||
useBasicCommandsFeature(fixture.host);
|
||||
expect(fixture.initialiseHandlers).toHaveLength(1);
|
||||
expect(fixture.commands).toHaveLength(0);
|
||||
await fixture.initialiseHandlers[0]?.();
|
||||
}
|
||||
|
||||
describe("useBasicCommandsFeature", () => {
|
||||
it("registers all established commands only when initialisation runs", async () => {
|
||||
const fixture = createFixture();
|
||||
|
||||
useBasicCommandsFeature(fixture.host);
|
||||
|
||||
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.api.addCommand).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.initialiseHandlers[0]?.();
|
||||
|
||||
expect(fixture.commands.map(({ id }) => id)).toEqual([
|
||||
"livesync-replicate",
|
||||
"livesync-dump",
|
||||
"livesync-toggle",
|
||||
"livesync-suspendall",
|
||||
"livesync-scan-files",
|
||||
"livesync-runbatch",
|
||||
"livesync-abortsync",
|
||||
]);
|
||||
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
|
||||
expect(fixture.getCommand("livesync-dump").name).toBe("Copy database information for the active file");
|
||||
expect(fixture.getCommand("livesync-toggle").name).toBe("Toggle LiveSync");
|
||||
expect(fixture.getCommand("livesync-suspendall").name).toBe("Toggle All Sync.");
|
||||
expect(fixture.getCommand("livesync-scan-files").name).toBe("Scan storage and database again");
|
||||
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
|
||||
expect(fixture.getCommand("livesync-abortsync").name).toBe("Abort synchronization immediately");
|
||||
});
|
||||
|
||||
it("retains the manual replication authority and quiet progress presentation", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-replicate").callback?.();
|
||||
|
||||
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles LiveSync and persists the updated setting", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-toggle").callback?.();
|
||||
|
||||
expect(fixture.settings.liveSync).toBe(true);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Enabled.", expect.anything(), "");
|
||||
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
await fixture.getCommand("livesync-toggle").callback?.();
|
||||
|
||||
expect(fixture.settings.liveSync).toBe(false);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("LiveSync Disabled.", expect.anything(), "");
|
||||
});
|
||||
|
||||
it("toggles all synchronisation through the app lifecycle and persists it", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-suspendall").callback?.();
|
||||
|
||||
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenCalledWith(true);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync suspended", expect.anything(), "");
|
||||
expect(fixture.services.control.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.setting.saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.control.applySettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
fixture.services.setting.saveSettingData.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
fixture.services.appLifecycle.isSuspended.mockReturnValue(true);
|
||||
await fixture.getCommand("livesync-suspendall").callback?.();
|
||||
|
||||
expect(fixture.services.appLifecycle.setSuspended).toHaveBeenLastCalledWith(false);
|
||||
expect(fixture.api.addLog).toHaveBeenCalledWith("Self-hosted LiveSync resumed", expect.anything(), "");
|
||||
});
|
||||
|
||||
it("keeps advanced maintenance checks gated and invokes their exact actions", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
|
||||
|
||||
fixture.settings.useAdvancedMode = true;
|
||||
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
|
||||
expect(fixture.services.vault.scanVault).not.toHaveBeenCalled();
|
||||
expect(fixture.services.replication.stopActiveTransfer).not.toHaveBeenCalled();
|
||||
|
||||
fixture.getCommand("livesync-scan-files").checkCallback?.(false);
|
||||
fixture.getCommand("livesync-abortsync").checkCallback?.(false);
|
||||
await vi.waitFor(() => {
|
||||
expect(fixture.services.vault.scanVault).toHaveBeenCalledWith(true);
|
||||
expect(fixture.services.replication.stopActiveTransfer).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("copies active-file database information through a narrow structural adapter", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
const dump = fixture.getCommand("livesync-dump");
|
||||
vi.mocked(copyFileDatabaseInfo).mockClear();
|
||||
expect(dump.checkCallback?.(true)).toBe(true);
|
||||
expect(copyFileDatabaseInfo).not.toHaveBeenCalled();
|
||||
dump.checkCallback?.(false);
|
||||
|
||||
await vi.waitFor(() => expect(copyFileDatabaseInfo).toHaveBeenCalledOnce());
|
||||
const [adapter, path] = vi.mocked(copyFileDatabaseInfo).mock.calls[0] ?? [];
|
||||
expect(path).toBe("note.md");
|
||||
expect(adapter).toEqual({
|
||||
localDatabase: fixture.services.database.localDatabase,
|
||||
services: {
|
||||
path: fixture.services.path,
|
||||
UI: fixture.services.UI,
|
||||
},
|
||||
settings: fixture.settings,
|
||||
storageAccess: fixture.serviceModules.storageAccess,
|
||||
});
|
||||
expect(adapter).not.toBe(fixture.host);
|
||||
});
|
||||
|
||||
it("commits pending file events from the batch command", async () => {
|
||||
const fixture = createFixture();
|
||||
await initialise(fixture);
|
||||
|
||||
await fixture.getCommand("livesync-runbatch").callback?.();
|
||||
|
||||
expect(fixture.services.fileProcessing.commitPendingFileEvents).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the active-file report unavailable without an active file", async () => {
|
||||
const fixture = createFixture();
|
||||
fixture.services.vault.getActiveFilePath.mockReturnValue(undefined);
|
||||
await initialise(fixture);
|
||||
|
||||
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type ConfiguredStartupLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
|
||||
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
waitForCompatibilityReview: vi.fn(async () => {}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs configured checks in order before allowing initialisation", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
|
||||
});
|
||||
|
||||
it("keeps Config Doctor behind the initial compatibility review", async () => {
|
||||
const runtime = createRuntime();
|
||||
Object.assign(runtime, {
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
runtime.events.push("compatibility-review");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops before onboarding or checks when the database is unavailable", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.databaseReady = false;
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["database-not-ready"]);
|
||||
});
|
||||
|
||||
it("stops the configured sequence at the first failed check", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
runtime.events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows a configured Vault to continue to database initialisation", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { IConflictService, IVaultService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
|
||||
|
||||
export type ConflictCheckingSettings = Pick<ObsidianLiveSyncSettings, "checkConflictOnlyOnOpen">;
|
||||
|
||||
export interface ConflictCheckingDependencies {
|
||||
readonly conflict: Pick<
|
||||
IConflictService,
|
||||
"getOptionalConflictCheckMethod" | "queueCheckFor" | "resolve" | "resolveByNewest"
|
||||
>;
|
||||
readonly conflictProcessQueueCount: ReactiveSource<number>;
|
||||
readonly currentSettings: () => ConflictCheckingSettings;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface ConflictCheckingHandlers {
|
||||
readonly queueCheckForIfOpen: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly queueCheckFor: (file: FilePathWithPrefix) => Promise<void>;
|
||||
readonly ensureAllProcessed: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Create conflict-checking handlers while retaining scheduling state privately. */
|
||||
export function createConflictCheckingHandlers(dependencies: ConflictCheckingDependencies): ConflictCheckingHandlers {
|
||||
const conflictQueue = new QueueProcessor<FilePathWithPrefix, void>(
|
||||
async (filenames: FilePathWithPrefix[]) => {
|
||||
const filename = filenames[0];
|
||||
return await dependencies.conflict.resolve(filename);
|
||||
},
|
||||
{
|
||||
suspended: false,
|
||||
batchSize: 1,
|
||||
// No need to limit concurrency to `1` here, subsequent process will handle it,
|
||||
// and some cases do not need to be synchronised (for example, auto-merge).
|
||||
// Global concurrency is limited by the resolver with the UI.
|
||||
concurrentLimit: 10,
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: false,
|
||||
totalRemainingReactiveSource: dependencies.conflictProcessQueueCount,
|
||||
}
|
||||
).replaceEnqueueProcessor((queue, newEntity) => {
|
||||
const newQueue = [...queue].filter((entry) => entry != newEntity);
|
||||
return [...newQueue, newEntity];
|
||||
});
|
||||
|
||||
const queueCheckForIfOpen = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
const path = file;
|
||||
if (dependencies.currentSettings().checkConflictOnlyOnOpen) {
|
||||
const activeFile: FilePath | undefined = dependencies.vault.getActiveFilePath();
|
||||
if (activeFile && activeFile != path) {
|
||||
dependencies.log(`${file} is conflicted, merging process has been postponed.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await dependencies.conflict.queueCheckFor(path);
|
||||
};
|
||||
|
||||
const queueCheckFor = async (file: FilePathWithPrefix): Promise<void> => {
|
||||
const optionalConflictResult = await dependencies.conflict.getOptionalConflictCheckMethod(file);
|
||||
if (optionalConflictResult == true) {
|
||||
// The conflict has been resolved by another process.
|
||||
return;
|
||||
} else if (optionalConflictResult === "newer") {
|
||||
// The conflict should be resolved by the newer entry.
|
||||
await dependencies.conflict.resolveByNewest(file);
|
||||
} else {
|
||||
conflictQueue.enqueue(file);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAllProcessed = (): Promise<boolean> => conflictQueue.waitForAllProcessed();
|
||||
|
||||
return {
|
||||
queueCheckForIfOpen,
|
||||
queueCheckFor,
|
||||
ensureAllProcessed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import type { ConflictResolutionHost } from "./index";
|
||||
import { createConflictResolutionOperations, useConflictResolutionFeature } from "./index";
|
||||
import type { ConflictResolutionOperationsDependencies } from "./operations";
|
||||
|
||||
type ConflictLeaf = {
|
||||
rev: string;
|
||||
data: string;
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
type HarnessOptions = {
|
||||
files?: FilePathWithPrefix[];
|
||||
settings?: Partial<ObsidianLiveSyncSettings>;
|
||||
activeFile?: FilePathWithPrefix;
|
||||
compose?: boolean;
|
||||
};
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const context = createServiceContext();
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const settings = { ...DEFAULT_SETTINGS, ...options.settings };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => [] as string[]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
};
|
||||
const fileHandler = {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
};
|
||||
const addLog = vi.fn();
|
||||
const storageAccess = {
|
||||
getFileNames: vi.fn(async () => options.files ?? []),
|
||||
};
|
||||
const activeFile = vi.fn(() => options.activeFile);
|
||||
const services = {
|
||||
API: { addLog },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict,
|
||||
context,
|
||||
database: { localDatabase: { tryAutoMerge } },
|
||||
replication: { replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })) },
|
||||
setting: { currentSettings: vi.fn(() => settings) },
|
||||
vault: { getActiveFilePath: activeFile },
|
||||
};
|
||||
const serviceModules = { databaseFileAccess, fileHandler, storageAccess };
|
||||
|
||||
if (options.compose !== false) {
|
||||
useConflictResolutionFeature({ services, serviceModules } as unknown as ConflictResolutionHost);
|
||||
}
|
||||
|
||||
return {
|
||||
addLog,
|
||||
conflict,
|
||||
context,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
services,
|
||||
serviceModules,
|
||||
storageAccess,
|
||||
tryAutoMerge,
|
||||
};
|
||||
}
|
||||
|
||||
function leaf(rev: string, data: string, mtime: number): ConflictLeaf {
|
||||
return { rev, data, mtime, ctime: mtime, deleted: false };
|
||||
}
|
||||
|
||||
function metadata(path: FilePathWithPrefix, rev: string, mtime: number): MetaEntry {
|
||||
return {
|
||||
_id: "doc-id",
|
||||
_rev: rev,
|
||||
path,
|
||||
ctime: mtime,
|
||||
mtime,
|
||||
size: 0,
|
||||
children: [],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as MetaEntry;
|
||||
}
|
||||
|
||||
function createOperationsHarness() {
|
||||
const events = { emitEvent: vi.fn() };
|
||||
const tryAutoMerge = vi.fn();
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => [] as string[]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
};
|
||||
const fileHandler = {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
};
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const resolveByUserInteraction = vi.fn(async () => false);
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const dependencies = {
|
||||
events,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
localDatabase: () => ({ tryAutoMerge }),
|
||||
conflict: { queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction },
|
||||
replication: { replicateUnattendedByEvent },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
||||
storageAccess: { getFileNames: vi.fn(async () => [] as FilePathWithPrefix[]) },
|
||||
currentSettings: vi.fn(() => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: true,
|
||||
showMergeDialogOnlyOnActive: false,
|
||||
})),
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies;
|
||||
|
||||
return {
|
||||
...dependencies,
|
||||
dependencies,
|
||||
operations: createConflictResolutionOperations(dependencies),
|
||||
events,
|
||||
tryAutoMerge,
|
||||
databaseFileAccess,
|
||||
fileHandler,
|
||||
resolveByDeletingRevision,
|
||||
resolveByUserInteraction,
|
||||
queueCheckFor,
|
||||
replicateUnattendedByEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("conflict resolution serviceFeature", () => {
|
||||
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the older conflict leaf when matching current content is newer", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same-newer-current.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("2-left", "Same content\n", 3000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-right", "same");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: "binary content",
|
||||
path: "different.png" as FilePathWithPrefix,
|
||||
resolveConflictsByNewerFile: false,
|
||||
expectedSubtitle: "binary",
|
||||
},
|
||||
{
|
||||
description: "the newer-file policy",
|
||||
path: "different.md" as FilePathWithPrefix,
|
||||
resolveConflictsByNewerFile: true,
|
||||
expectedSubtitle: "alwaysNewer",
|
||||
},
|
||||
])(
|
||||
"retains automatic resolution for $description",
|
||||
async ({ path, resolveConflictsByNewerFile, expectedSubtitle }) => {
|
||||
const harness = createOperationsHarness();
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("2-right", "Right content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
const operations = createConflictResolutionOperations({
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", expectedSubtitle);
|
||||
}
|
||||
);
|
||||
|
||||
it("acquires the active local database for each resolution attempt", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "database-reset.md" as FilePathWithPrefix;
|
||||
const firstTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
|
||||
const replacementTryAutoMerge = vi.fn(async () => ({ ok: NOT_CONFLICTED as typeof NOT_CONFLICTED }));
|
||||
let activeDatabase = { tryAutoMerge: firstTryAutoMerge };
|
||||
const dependencies = {
|
||||
...harness.dependencies,
|
||||
localDatabase: () => activeDatabase,
|
||||
};
|
||||
const operations = createConflictResolutionOperations(dependencies);
|
||||
|
||||
await operations.checkConflictAndPerformAutoMerge(path);
|
||||
activeDatabase = { tryAutoMerge: replacementTryAutoMerge };
|
||||
await operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(firstTryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(replacementTryAutoMerge).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns a manual diff for independently created files with different content", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
const leftLeaf = leaf("1-left", "Left content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Right content\n", 2000);
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
|
||||
expect(result).toHaveProperty("diff");
|
||||
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores a sensible merge before resolving its conflict leaf", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "sensible.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
result: "Title\nLeft changed\nRight changed\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(
|
||||
path,
|
||||
"Title\nLeft changed\nRight changed\n"
|
||||
);
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
|
||||
});
|
||||
|
||||
it("keeps the conflict leaf when sensible merged content cannot be stored", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-sensible.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
result: "Merged content\n",
|
||||
conflictedRev: "2-right",
|
||||
});
|
||||
harness.databaseFileAccess.storeContent.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops before emitting or reflecting when conflict revision deletion fails", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-delete.md" as FilePathWithPrefix;
|
||||
harness.fileHandler.deleteRevisionFromDB.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.events.emitEvent).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a safe error and logs when reflecting the resolved revision fails", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "failed-reflection.md" as FilePathWithPrefix;
|
||||
harness.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||
expect(harness.dependencies.log).toHaveBeenCalledWith(
|
||||
`Could not write the resolved content to the storage: ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["plugin metadata", "ps:plugin-metadata" as FilePathWithPrefix],
|
||||
["customisation metadata", "ix:customisation-metadata" as FilePathWithPrefix],
|
||||
])("does not reflect %s into the Vault after deleting its revision", async (_description, path) => {
|
||||
const harness = createOperationsHarness();
|
||||
|
||||
const result = await harness.operations.resolveByDeletingRevision(path, "2-right", "UI Selected");
|
||||
|
||||
expect(result).toBe(AUTO_MERGED);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "2-right");
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks a remaining manual pair after committing a sensible merge", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "three-versions.md" as FilePathWithPrefix;
|
||||
const remainingManualPair = {
|
||||
leftRev: "3-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: leaf("3-merged", "Merged\n", 3),
|
||||
rightLeaf: leaf("2-third", "Overlapping\n", 2),
|
||||
};
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
result: "Merged\n",
|
||||
conflictedRev: "2-second",
|
||||
})
|
||||
.mockResolvedValueOnce(remainingManualPair);
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
|
||||
expect(harness.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({
|
||||
left: remainingManualPair.leftLeaf,
|
||||
right: remainingManualPair.rightLeaf,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("requeues and replicates through collaborators after an automatic merge", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "merged.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: AUTO_MERGED });
|
||||
|
||||
await harness.operations.resolve(path);
|
||||
|
||||
expect(harness.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(harness.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("postpones a manual merge until its file is active when configured", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "inactive.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
const dependencies = {
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
}),
|
||||
vault: { getActiveFilePath: () => "other.md" as FilePath },
|
||||
};
|
||||
const operations = createConflictResolutionOperations(dependencies);
|
||||
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(harness.resolveByUserInteraction).not.toHaveBeenCalled();
|
||||
expect(dependencies.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Merging process has been postponed"),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a manual merge when the active-only setting matches the current file", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "active.md" as FilePathWithPrefix;
|
||||
const activePath = "active.md" as FilePath;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
const operations = createConflictResolutionOperations({
|
||||
...harness.dependencies,
|
||||
currentSettings: () => ({
|
||||
disableMarkdownAutoMerge: false,
|
||||
resolveConflictsByNewerFile: false,
|
||||
syncAfterMerge: false,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
}),
|
||||
vault: { getActiveFilePath: () => activePath },
|
||||
});
|
||||
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledWith(
|
||||
path,
|
||||
expect.objectContaining({ left: expect.anything(), right: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels an active same-file dialogue before serialising a repeated resolution", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "repeated.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
let finishDialogue: ((result: boolean) => void) | undefined;
|
||||
harness.resolveByUserInteraction.mockImplementation(
|
||||
async () => await new Promise<boolean>((resolve) => (finishDialogue = resolve))
|
||||
);
|
||||
harness.events.emitEvent.mockImplementation((event, filename) => {
|
||||
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishDialogue) {
|
||||
const finish = finishDialogue;
|
||||
finishDialogue = undefined;
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
|
||||
const replacement = harness.operations.resolve(path);
|
||||
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2));
|
||||
expect(harness.events.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
|
||||
finishDialogue?.(false);
|
||||
await Promise.all([first, replacement]);
|
||||
});
|
||||
|
||||
it("passes only the newest waiting same-file resolution to the interactive resolver", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "repeated-three-times.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
});
|
||||
let finishFirstDialogue: ((result: boolean) => void) | undefined;
|
||||
harness.resolveByUserInteraction
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<boolean>((resolve) => (finishFirstDialogue = resolve))
|
||||
)
|
||||
.mockResolvedValue(false);
|
||||
harness.events.emitEvent.mockImplementation((event, filename) => {
|
||||
if (event === EVENT_CONFLICT_CANCELLED && filename === path && finishFirstDialogue) {
|
||||
const finish = finishFirstDialogue;
|
||||
finishFirstDialogue = undefined;
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
|
||||
const first = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce());
|
||||
const superseded = harness.operations.resolve(path);
|
||||
const replacement = harness.operations.resolve(path);
|
||||
|
||||
await Promise.all([first, superseded, replacement]);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not open a superseded dialogue after conflict inspection completes", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "superseded-during-inspection.md" as FilePathWithPrefix;
|
||||
const manualConflict = {
|
||||
leftRev: "2-left",
|
||||
rightRev: "2-right",
|
||||
leftLeaf: leaf("2-left", "Left\n", 1),
|
||||
rightLeaf: leaf("2-right", "Right\n", 2),
|
||||
};
|
||||
let finishFirstInspection!: (result: typeof manualConflict) => void;
|
||||
harness.tryAutoMerge
|
||||
.mockImplementationOnce(
|
||||
async () => await new Promise<typeof manualConflict>((resolve) => (finishFirstInspection = resolve))
|
||||
)
|
||||
.mockResolvedValue(manualConflict);
|
||||
harness.resolveByUserInteraction.mockResolvedValue(false);
|
||||
|
||||
const superseded = harness.operations.resolve(path);
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
|
||||
const replacement = harness.operations.resolve(path);
|
||||
finishFirstInspection(manualConflict);
|
||||
|
||||
await Promise.all([superseded, replacement]);
|
||||
|
||||
expect(harness.resolveByUserInteraction).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("registers all conflict service operations during composition", () => {
|
||||
const harness = createHarness({ compose: false });
|
||||
const registrations = [
|
||||
vi.spyOn(harness.conflict.queueCheckForIfOpen, "setHandler"),
|
||||
vi.spyOn(harness.conflict.queueCheckFor, "setHandler"),
|
||||
vi.spyOn(harness.conflict.ensureAllProcessed, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveByDeletingRevision, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolve, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveByNewest, "setHandler"),
|
||||
vi.spyOn(harness.conflict.resolveAllConflictedFilesByNewerOnes, "setHandler"),
|
||||
];
|
||||
|
||||
useConflictResolutionFeature({
|
||||
services: harness.services,
|
||||
serviceModules: harness.serviceModules,
|
||||
} as unknown as ConflictResolutionHost);
|
||||
|
||||
for (const registration of registrations) {
|
||||
expect(registration).toHaveBeenCalledOnce();
|
||||
expect(registration).toHaveBeenCalledWith(expect.any(Function));
|
||||
}
|
||||
});
|
||||
|
||||
it("applies the active-file gate and deduplicates pending checks for one path", async () => {
|
||||
const path = "postponed.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({
|
||||
activeFile: "other.md" as FilePathWithPrefix,
|
||||
settings: { checkConflictOnlyOnOpen: true },
|
||||
});
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen(path);
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
|
||||
harness.services.vault.getActiveFilePath = vi.fn(() => path);
|
||||
await Promise.all([harness.conflict.queueCheckFor(path), harness.conflict.queueCheckFor(path)]);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`${path} is conflicted, merging process has been postponed.`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the active-file gate is disabled", { checkConflictOnlyOnOpen: false }, "other.md" as FilePathWithPrefix],
|
||||
["the requested path is active", { checkConflictOnlyOnOpen: true }, "active.md" as FilePathWithPrefix],
|
||||
])("reaches the resolver when %s", async (_description, settings, activeFile) => {
|
||||
const path = "active.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings, activeFile });
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckForIfOpen(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledOnce();
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledWith(path, true);
|
||||
});
|
||||
|
||||
it("limits concurrent conflict checks and reports queued and active work", async () => {
|
||||
const paths = Array.from({ length: 11 }, (_, index) => `concurrent-${index}.md` as FilePathWithPrefix);
|
||||
const harness = createHarness();
|
||||
const finishByPath = new Map<FilePathWithPrefix, () => void>();
|
||||
harness.tryAutoMerge.mockImplementation(
|
||||
async (path: FilePathWithPrefix) =>
|
||||
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
|
||||
finishByPath.set(path, () => resolve({ ok: NOT_CONFLICTED }));
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(paths.map(async (path) => await harness.conflict.queueCheckFor(path)));
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
|
||||
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(11);
|
||||
let allProcessed = false;
|
||||
const completion = harness.conflict.ensureAllProcessed().then((result) => {
|
||||
allProcessed = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(allProcessed).toBe(false);
|
||||
|
||||
const finishFirst = finishByPath.get(paths[0]);
|
||||
finishByPath.delete(paths[0]);
|
||||
finishFirst?.();
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(11));
|
||||
for (const finish of finishByPath.values()) finish();
|
||||
|
||||
await expect(completion).resolves.toBe(true);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces an older same-path check while every resolver slot is occupied", async () => {
|
||||
const occupiedPaths = Array.from({ length: 10 }, (_, index) => `occupied-${index}.md` as FilePathWithPrefix);
|
||||
const repeatedPath = "waiting-replacement.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
const finishers: Array<{ path: FilePathWithPrefix; finish: () => void }> = [];
|
||||
harness.tryAutoMerge.mockImplementation(
|
||||
async (path: FilePathWithPrefix) =>
|
||||
await new Promise<{ ok: typeof NOT_CONFLICTED }>((resolve) => {
|
||||
finishers.push({ path, finish: () => resolve({ ok: NOT_CONFLICTED }) });
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(occupiedPaths.map(async (path) => await harness.conflict.queueCheckFor(path)));
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledTimes(10));
|
||||
|
||||
await harness.conflict.queueCheckFor(repeatedPath);
|
||||
await harness.conflict.queueCheckFor(repeatedPath);
|
||||
for (const { finish } of finishers.filter(({ path }) => path !== repeatedPath)) finish();
|
||||
|
||||
await vi.waitFor(() => expect(harness.conflict.conflictProcessQueueCount.value).toBe(1));
|
||||
expect(harness.tryAutoMerge.mock.calls.filter(([path]) => path === repeatedPath)).toHaveLength(1);
|
||||
|
||||
finishers.find(({ path }) => path === repeatedPath)?.finish();
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("waits for a conflict check requeued by an automatic merge", async () => {
|
||||
const path = "requeued-automatic-merge.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
let finishFirst!: (result: { ok: typeof AUTO_MERGED }) => void;
|
||||
harness.tryAutoMerge
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<{ ok: typeof AUTO_MERGED }>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
await vi.waitFor(() => expect(harness.tryAutoMerge).toHaveBeenCalledOnce());
|
||||
const completion = harness.conflict.ensureAllProcessed();
|
||||
|
||||
finishFirst({ ok: AUTO_MERGED });
|
||||
|
||||
await expect(completion).resolves.toBe(true);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
});
|
||||
|
||||
it("drains repeated manual resolutions for a file with more than two conflicting versions", async () => {
|
||||
const path = "requeued-manual-merge.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const firstPair = {
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-second",
|
||||
leftLeaf: leaf("3-current", "Current\n", 3),
|
||||
rightLeaf: leaf("2-second", "Second\n", 2),
|
||||
};
|
||||
const remainingPair = {
|
||||
leftRev: "4-merged",
|
||||
rightRev: "2-third",
|
||||
leftLeaf: leaf("4-merged", "Merged\n", 4),
|
||||
rightLeaf: leaf("2-third", "Third\n", 2),
|
||||
};
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce(firstPair)
|
||||
.mockResolvedValueOnce(remainingPair)
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
const resolvePair = vi.fn(async (filename: FilePathWithPrefix) => {
|
||||
await harness.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
});
|
||||
const unregister = harness.conflict.resolveByUserInteraction.addHandler(resolvePair);
|
||||
|
||||
try {
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
|
||||
await expect(harness.conflict.ensureAllProcessed()).resolves.toBe(true);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(3);
|
||||
expect(resolvePair).toHaveBeenCalledTimes(2);
|
||||
expect(harness.conflict.conflictProcessQueueCount.value).toBe(0);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("honours optional conflict handlers before entering the check queue", async () => {
|
||||
const path = "optional.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
harness.tryAutoMerge.mockResolvedValue({ ok: NOT_CONFLICTED });
|
||||
|
||||
const unregisterResolved = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => true);
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
expect(harness.tryAutoMerge).not.toHaveBeenCalled();
|
||||
unregisterResolved();
|
||||
|
||||
const unregisterNewer = harness.conflict.getOptionalConflictCheckMethod.addHandler(async () => "newer");
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockResolvedValue(false);
|
||||
await harness.conflict.queueCheckFor(path);
|
||||
expect(harness.databaseFileAccess.fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
|
||||
unregisterNewer();
|
||||
});
|
||||
|
||||
it("keeps unreadable conflict revisions available for explicit repair", async () => {
|
||||
const path = "missing-conflict-body.md" as FilePathWithPrefix;
|
||||
const harness = createHarness();
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-unreadable",
|
||||
leftLeaf: leaf("3-current", "Readable current body\n", 3),
|
||||
rightLeaf: false,
|
||||
});
|
||||
|
||||
await harness.conflict.resolve(path);
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
`could not read conflicted revision 2-unreadable:${path}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
""
|
||||
);
|
||||
expect(MISSING_OR_ERROR).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves revisions and returns a safe error when the current leaf is unreadable", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "unreadable-current.md" as FilePathWithPrefix;
|
||||
harness.tryAutoMerge.mockResolvedValue({
|
||||
leftRev: "3-current",
|
||||
rightRev: "2-conflict",
|
||||
leftLeaf: false,
|
||||
rightLeaf: leaf("2-conflict", "Conflict body\n", 2),
|
||||
});
|
||||
|
||||
const result = await harness.operations.checkConflictAndPerformAutoMerge(path);
|
||||
|
||||
expect(result).toBe(MISSING_OR_ERROR);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
expect(harness.fileHandler.dbToStorage).not.toHaveBeenCalled();
|
||||
expect(harness.dependencies.log).toHaveBeenCalledWith(
|
||||
`could not get current revisions:${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an identical pair, emits cancellation, and rechecks after merging", async () => {
|
||||
const path = "independently-created.md" as FilePathWithPrefix;
|
||||
const harness = createHarness({ settings: { syncAfterMerge: false } });
|
||||
const cancelled: FilePathWithPrefix[] = [];
|
||||
harness.context.events.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => cancelled.push(filename));
|
||||
const leftLeaf = leaf("1-left", "Same content\n", 1000);
|
||||
const rightLeaf = leaf("1-right", "Same content\n", 2000);
|
||||
harness.tryAutoMerge
|
||||
.mockResolvedValueOnce({
|
||||
leftRev: leftLeaf.rev,
|
||||
rightRev: rightLeaf.rev,
|
||||
leftLeaf,
|
||||
rightLeaf,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: NOT_CONFLICTED });
|
||||
|
||||
await harness.conflict.resolve(path);
|
||||
await harness.conflict.ensureAllProcessed();
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledWith(path, "1-left");
|
||||
expect(harness.fileHandler.dbToStorage).toHaveBeenCalledWith(path, path, true);
|
||||
expect(cancelled).toEqual([path, path]);
|
||||
expect(harness.tryAutoMerge).toHaveBeenCalledTimes(2);
|
||||
expect(AUTO_MERGED).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses deterministic revision ordering and suppresses notices during bulk resolution", async () => {
|
||||
const files = Array.from({ length: 11 }, (_, index) => `note-${index}.md` as FilePathWithPrefix);
|
||||
const harness = createHarness({ files });
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(async (path: FilePathWithPrefix, rev?: string) =>
|
||||
metadata(path, rev ?? "2-current", rev ? 1 : 2)
|
||||
);
|
||||
let conflictInspection = 0;
|
||||
harness.databaseFileAccess.getConflictedRevs.mockImplementation(async () =>
|
||||
conflictInspection++ % 2 === 0 ? ["1-old"] : []
|
||||
);
|
||||
|
||||
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(11);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
"Check and Processing 10 / 11",
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
expect(harness.addLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("has been merged automatically"),
|
||||
LOG_LEVEL_INFO,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
it("uses revision identifiers to break newest-resolution timestamp ties", async () => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "same-time.md" as FilePathWithPrefix;
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
|
||||
async (filename: FilePathWithPrefix, revision?: string) => metadata(filename, revision ?? "2-3", 1000)
|
||||
);
|
||||
harness.databaseFileAccess.getConflictedRevs
|
||||
.mockResolvedValueOnce(["2-10", "2-2"])
|
||||
.mockResolvedValueOnce(["2-10"])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await harness.operations.resolveByNewest(path);
|
||||
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(1, path, "2-3");
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(2, path, "2-10");
|
||||
expect(harness.dependencies.log).toHaveBeenLastCalledWith(
|
||||
`${path} has been merged automatically`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ description: "no conflicts remain", conflicts: [] as string[], expectedDeletes: [] as string[] },
|
||||
{
|
||||
description: "conflict metadata is unreadable",
|
||||
conflicts: ["2-unreadable"],
|
||||
expectedDeletes: ["2-unreadable"],
|
||||
},
|
||||
])("handles the case where $description while resolving by newest", async ({ conflicts, expectedDeletes }) => {
|
||||
const harness = createOperationsHarness();
|
||||
const path = "newest-policy.md" as FilePathWithPrefix;
|
||||
harness.databaseFileAccess.fetchEntryMeta.mockImplementation(
|
||||
async (_filename: FilePathWithPrefix, revision?: string) =>
|
||||
revision === undefined ? metadata(path, "3-current", 1000) : false
|
||||
);
|
||||
harness.databaseFileAccess.getConflictedRevs.mockResolvedValueOnce(conflicts).mockResolvedValueOnce([]);
|
||||
|
||||
await expect(harness.operations.resolveByNewest(path)).resolves.toBe(true);
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledTimes(expectedDeletes.length);
|
||||
for (const [index, revision] of expectedDeletes.entries()) {
|
||||
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenNthCalledWith(index + 1, path, revision);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createConflictCheckingHandlers } from "./checker";
|
||||
import { createConflictResolutionOperations } from "./operations";
|
||||
|
||||
type ConflictResolutionServices = NecessaryServices<
|
||||
"API" | "appLifecycle" | "conflict" | "database" | "replication" | "setting" | "vault",
|
||||
"databaseFileAccess" | "fileHandler" | "storageAccess"
|
||||
>;
|
||||
|
||||
export type ConflictResolutionHost = ConflictResolutionServices & {
|
||||
readonly services: ConflictResolutionServices["services"] & {
|
||||
readonly conflict: InjectableConflictService<ServiceContext>;
|
||||
};
|
||||
};
|
||||
|
||||
/** Compose the host-neutral conflict checker and resolver handlers. */
|
||||
export function useConflictResolutionFeature(host: ConflictResolutionHost): void {
|
||||
const { services, serviceModules } = host;
|
||||
const log = createInstanceLogFunction("SF:ConflictResolution", services.API);
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: serviceModules.databaseFileAccess,
|
||||
fileHandler: serviceModules.fileHandler,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
conflict: services.conflict,
|
||||
replication: services.replication,
|
||||
appLifecycle: services.appLifecycle,
|
||||
vault: services.vault,
|
||||
storageAccess: serviceModules.storageAccess,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
log,
|
||||
});
|
||||
const checking = createConflictCheckingHandlers({
|
||||
conflict: services.conflict,
|
||||
conflictProcessQueueCount: services.conflict.conflictProcessQueueCount,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
vault: services.vault,
|
||||
log,
|
||||
});
|
||||
|
||||
services.conflict.queueCheckForIfOpen.setHandler(checking.queueCheckForIfOpen);
|
||||
services.conflict.queueCheckFor.setHandler(checking.queueCheckFor);
|
||||
services.conflict.ensureAllProcessed.setHandler(checking.ensureAllProcessed);
|
||||
services.conflict.resolveByDeletingRevision.setHandler(operations.resolveByDeletingRevision);
|
||||
services.conflict.resolve.setHandler(operations.resolve);
|
||||
services.conflict.resolveByNewest.setHandler(operations.resolveByNewest);
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(operations.resolveAllConflictedFilesByNewerOnes);
|
||||
}
|
||||
|
||||
export { createConflictCheckingHandlers } from "./checker";
|
||||
export type { ConflictCheckingDependencies, ConflictCheckingHandlers } from "./checker";
|
||||
export type {
|
||||
ConflictResolutionOperations,
|
||||
ConflictResolutionOperationsDependencies,
|
||||
ConflictResolutionSettings,
|
||||
} from "./operations";
|
||||
export { createConflictResolutionOperations } from "./operations";
|
||||
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
NOT_CONFLICTED,
|
||||
type diff_check_result,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCustomisationSyncMetadata, isPluginMetadata } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import type {
|
||||
IAppLifecycleService,
|
||||
IConflictService,
|
||||
IReplicationService,
|
||||
IVaultService,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { isLockAcquired, serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import diff_match_patch from "diff-match-patch";
|
||||
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
export type ConflictResolutionSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
"disableMarkdownAutoMerge" | "resolveConflictsByNewerFile" | "syncAfterMerge" | "showMergeDialogOnlyOnActive"
|
||||
>;
|
||||
|
||||
export interface ConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "fetchEntryMeta" | "getConflictedRevs" | "storeContent">;
|
||||
readonly fileHandler: Pick<IFileHandler, "deleteRevisionFromDB" | "dbToStorage">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "tryAutoMerge">;
|
||||
readonly conflict: Pick<
|
||||
IConflictService,
|
||||
"queueCheckFor" | "resolveByDeletingRevision" | "resolveByUserInteraction"
|
||||
>;
|
||||
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
|
||||
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly storageAccess: Pick<StorageAccess, "getFileNames">;
|
||||
readonly currentSettings: () => ConflictResolutionSettings;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface ConflictResolutionOperations {
|
||||
readonly resolveByDeletingRevision: (
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle?: string,
|
||||
showNotice?: boolean
|
||||
) => Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED>;
|
||||
readonly checkConflictAndPerformAutoMerge: (path: FilePathWithPrefix) => Promise<diff_check_result>;
|
||||
readonly resolve: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly resolveByNewest: (filename: FilePathWithPrefix, showNotice?: boolean) => Promise<boolean>;
|
||||
readonly resolveAllConflictedFilesByNewerOnes: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createConflictResolutionOperations(
|
||||
dependencies: ConflictResolutionOperationsDependencies
|
||||
): ConflictResolutionOperations {
|
||||
const latestResolveRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextResolveRequestId = 0;
|
||||
|
||||
const resolveByDeletingRevision = async (
|
||||
path: FilePathWithPrefix,
|
||||
deleteRevision: string,
|
||||
subTitle = "",
|
||||
showNotice = true
|
||||
): Promise<typeof MISSING_OR_ERROR | typeof AUTO_MERGED> => {
|
||||
const title = `Resolving ${subTitle ? `[${subTitle}]` : ""}:`;
|
||||
if (!(await dependencies.fileHandler.deleteRevisionFromDB(path, deleteRevision))) {
|
||||
dependencies.log(
|
||||
`${title} Could not delete conflicted revision ${displayRev(deleteRevision)} of ${path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
dependencies.log(
|
||||
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
if ((await dependencies.databaseFileAccess.getConflictedRevs(path)).length != 0) {
|
||||
dependencies.log(`${title} some conflicts are left in ${path}`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
if (isPluginMetadata(path) || isCustomisationSyncMetadata(path)) {
|
||||
dependencies.log(`${title} ${path} is a plugin metadata file, no need to write to storage`, LOG_LEVEL_INFO);
|
||||
return AUTO_MERGED;
|
||||
}
|
||||
// If no conflicts were found, write the resolved content to the storage.
|
||||
if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) {
|
||||
dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
const level = subTitle.indexOf("same") !== -1 || !showNotice ? LOG_LEVEL_INFO : LOG_LEVEL_NOTICE;
|
||||
dependencies.log(`${path} has been merged automatically`, level);
|
||||
return AUTO_MERGED;
|
||||
};
|
||||
|
||||
const checkConflictAndPerformAutoMerge = async (path: FilePathWithPrefix): Promise<diff_check_result> => {
|
||||
const ret = await dependencies
|
||||
.localDatabase()
|
||||
.tryAutoMerge(path, !dependencies.currentSettings().disableMarkdownAutoMerge);
|
||||
if ("ok" in ret) {
|
||||
return ret.ok;
|
||||
}
|
||||
|
||||
if ("result" in ret) {
|
||||
const p = ret.result;
|
||||
// 1. Store the merged content to the storage.
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(path, p))) {
|
||||
dependencies.log(`Merged content cannot be stored:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
// 2. Delete the conflicted revision and reflect the result if all conflicts are gone.
|
||||
return await dependencies.conflict.resolveByDeletingRevision(path, ret.conflictedRev, "Sensible");
|
||||
}
|
||||
|
||||
const { rightRev, leftLeaf, rightLeaf } = ret;
|
||||
|
||||
// Should be one or more conflicts.
|
||||
if (leftLeaf == false) {
|
||||
dependencies.log(`could not get current revisions:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
if (rightLeaf == false) {
|
||||
// A locally unreadable conflict leaf may still be recoverable from another
|
||||
// replica or backup. Keep it visible for explicit repair instead of treating
|
||||
// missing chunks as evidence that the branch is obsolete.
|
||||
dependencies.log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
|
||||
return MISSING_OR_ERROR;
|
||||
}
|
||||
|
||||
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
|
||||
const isBinary = !isPlainText(path);
|
||||
const alwaysNewer = dependencies.currentSettings().resolveConflictsByNewerFile;
|
||||
if (isSame || isBinary || alwaysNewer) {
|
||||
const result = compareMTime(leftLeaf.mtime, rightLeaf.mtime);
|
||||
let loser = leftLeaf;
|
||||
// If lMtime > rMtime.
|
||||
if (result != TARGET_IS_NEW) {
|
||||
loser = rightLeaf;
|
||||
}
|
||||
const subTitle = [
|
||||
`${isSame ? "same" : ""}`,
|
||||
`${isBinary ? "binary" : ""}`,
|
||||
`${alwaysNewer ? "alwaysNewer" : ""}`,
|
||||
]
|
||||
.filter((e) => e.trim())
|
||||
.join(",");
|
||||
return await dependencies.conflict.resolveByDeletingRevision(path, loser.rev, subTitle);
|
||||
}
|
||||
// Make diff.
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(leftLeaf.data, rightLeaf.data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
dependencies.log(`conflict(s) found:${path}`);
|
||||
return {
|
||||
left: leftLeaf,
|
||||
right: rightLeaf,
|
||||
diff: diff,
|
||||
};
|
||||
};
|
||||
|
||||
const resolve = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
const requestId = ++nextResolveRequestId;
|
||||
latestResolveRequestByFilename.set(filename, requestId);
|
||||
const serialisationKey = `conflict-resolve:${filename}`;
|
||||
if (isLockAcquired(serialisationKey)) {
|
||||
// A later check for the same file makes any open comparison stale.
|
||||
// Close it before waiting for the current resolver to release the
|
||||
// per-file lock. Dialogues for other paths remain untouched.
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
}
|
||||
return await serialized(serialisationKey, async () => {
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conflictCheckResult = await checkConflictAndPerformAutoMerge(filename);
|
||||
if (latestResolveRequestByFilename.get(filename) !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === NOT_CONFLICTED) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
|
||||
// Nothing to do.
|
||||
dependencies.log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (conflictCheckResult === AUTO_MERGED) {
|
||||
// Auto resolved, but need to check again.
|
||||
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
|
||||
// Wait for the running replication, if not running replication, run it once.
|
||||
await dependencies.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
dependencies.log("[conflict] Automatically merged, but we have to check it again");
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return;
|
||||
}
|
||||
if (dependencies.currentSettings().showMergeDialogOnlyOnActive) {
|
||||
const activeFile = dependencies.vault.getActiveFilePath();
|
||||
if (activeFile && activeFile != filename) {
|
||||
dependencies.log(
|
||||
`[conflict] ${filename} is conflicted. Merging process has been postponed to the file have got opened.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dependencies.log("[conflict] Manual merge required!");
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
await dependencies.conflict.resolveByUserInteraction(filename, conflictCheckResult);
|
||||
} finally {
|
||||
if (latestResolveRequestByFilename.get(filename) === requestId) {
|
||||
latestResolveRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resolveByNewest = async (filename: FilePathWithPrefix, showNotice = true): Promise<boolean> => {
|
||||
const currentRev = await dependencies.databaseFileAccess.fetchEntryMeta(filename, undefined, true);
|
||||
if (currentRev == false) {
|
||||
dependencies.log(`Could not get current revision of ${filename}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const revs = await dependencies.databaseFileAccess.getConflictedRevs(filename);
|
||||
if (revs.length == 0) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
const mTimeAndRev = (
|
||||
[
|
||||
[currentRev.mtime, currentRev._rev],
|
||||
...(await Promise.all(
|
||||
revs.map(async (rev) => {
|
||||
const leaf = await dependencies.databaseFileAccess.fetchEntryMeta(filename, rev);
|
||||
if (leaf == false) {
|
||||
return [0, rev];
|
||||
}
|
||||
return [leaf.mtime, rev];
|
||||
})
|
||||
)),
|
||||
] as [number, string][]
|
||||
).sort((a, b) => {
|
||||
const diff = b[0] - a[0];
|
||||
if (diff == 0) {
|
||||
return a[1].localeCompare(b[1], "en", { numeric: true });
|
||||
}
|
||||
return diff;
|
||||
});
|
||||
dependencies.log(
|
||||
`Resolving conflict by newest: ${filename} (Newest: ${new Date(mTimeAndRev[0][0]).toLocaleString()}) (${mTimeAndRev.length} revisions exists)`
|
||||
);
|
||||
for (let i = 1; i < mTimeAndRev.length; i++) {
|
||||
dependencies.log(
|
||||
`conflict: Deleting the older revision ${mTimeAndRev[i][1]} (${new Date(mTimeAndRev[i][0]).toLocaleString()}) of ${filename}`
|
||||
);
|
||||
await resolveByDeletingRevision(filename, mTimeAndRev[i][1], "NEWEST", showNotice);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
|
||||
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
const files = await dependencies.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
i++;
|
||||
if (i % 10 === 0)
|
||||
dependencies.log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await resolveByNewest(file, false);
|
||||
}
|
||||
dependencies.log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
};
|
||||
|
||||
return {
|
||||
resolveByDeletingRevision,
|
||||
checkConflictAndPerformAutoMerge,
|
||||
resolve,
|
||||
resolveByNewest,
|
||||
resolveAllConflictedFilesByNewerOnes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@/common/events.ts";
|
||||
import { createInteractiveConflictResolutionOperations } from "./operations";
|
||||
import type { ConflictResolveDialogueFactory } from "./types";
|
||||
|
||||
export type InteractiveConflictResolutionHost = NecessaryServices<
|
||||
"API" | "UI" | "appLifecycle" | "conflict" | "replication" | "vault" | "database" | "setting" | "path",
|
||||
"databaseFileAccess"
|
||||
>;
|
||||
|
||||
export function useInteractiveConflictResolutionFeature(
|
||||
host: InteractiveConflictResolutionHost,
|
||||
createDialogue: ConflictResolveDialogueFactory
|
||||
): void {
|
||||
const services = host.services;
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: services.context.events,
|
||||
databaseFileAccess: host.serviceModules.databaseFileAccess,
|
||||
localDatabase: () => services.database.localDatabase,
|
||||
confirm: services.UI.confirm,
|
||||
path: services.path,
|
||||
vault: services.vault,
|
||||
appLifecycle: services.appLifecycle,
|
||||
conflict: services.conflict,
|
||||
replication: services.replication,
|
||||
currentSettings: () => services.setting.currentSettings(),
|
||||
createDialogue,
|
||||
log: createInstanceLogFunction("SF:InteractiveConflictResolution", services.API),
|
||||
});
|
||||
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(operations.scanStartupIssues);
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
services.API.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void operations.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
callback: async () => {
|
||||
await operations.pickFileForResolve();
|
||||
},
|
||||
});
|
||||
services.API.addCommand({
|
||||
id: "livesync-all-conflictcheck",
|
||||
name: "Resolve all conflicted files",
|
||||
callback: async () => {
|
||||
await operations.allConflictCheck();
|
||||
},
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(operations.getActiveConflictMessages);
|
||||
services.conflict.resolveByUserInteraction.addHandler(operations.resolveByUserInteraction);
|
||||
const eventSubscriptions = new AbortController();
|
||||
const dispose = () => {
|
||||
// Stop the refresh listener before cancellation so that unloading does
|
||||
// not start a database read which can race with database disposal.
|
||||
eventSubscriptions.abort();
|
||||
operations.dispose();
|
||||
};
|
||||
services.context.events.onEvent(
|
||||
EVENT_CONFLICT_CANCELLED,
|
||||
(filename) => {
|
||||
operations.invalidateWaitingResolution(filename);
|
||||
fireAndForget(() => operations.refreshConflictState(filename));
|
||||
},
|
||||
{ signal: eventSubscriptions.signal }
|
||||
);
|
||||
services.context.events.onceEvent(EVENT_PLUGIN_UNLOADED, dispose, { signal: eventSubscriptions.signal });
|
||||
services.appLifecycle.onUnload.addHandler(() => {
|
||||
dispose();
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
|
||||
export { createInteractiveConflictResolutionOperations } from "./operations";
|
||||
export type {
|
||||
InteractiveConflictResolutionOperations,
|
||||
InteractiveConflictResolutionOperationsDependencies,
|
||||
} from "./operations";
|
||||
export { POSTPONED } from "./types";
|
||||
export type { ConflictResolveDialogue, ConflictResolveDialogueFactory, MergeDialogResult } from "./types";
|
||||
+898
@@ -0,0 +1,898 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
MISSING_OR_ERROR,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, EVENT_PLUGIN_UNLOADED } from "@/common/events";
|
||||
import {
|
||||
createInteractiveConflictResolutionOperations,
|
||||
type InteractiveConflictResolutionOperationsDependencies,
|
||||
} from "./operations";
|
||||
import { POSTPONED, type ConflictResolveDialogueFactory, type MergeDialogResult } from "./types";
|
||||
import { useInteractiveConflictResolutionFeature } from "./index";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createOperations(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const context = createServiceContext();
|
||||
let dialogueResult: unknown = POSTPONED;
|
||||
const constructed = { value: 0 };
|
||||
const getDBEntry = vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false);
|
||||
const findAllDocs = vi.fn(() => documents([]));
|
||||
const askSelectString = vi.fn(async (): Promise<string> => "");
|
||||
const askInPopup = vi.fn();
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const ensureAllProcessed = vi.fn(async () => true);
|
||||
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
|
||||
const getConflictedRevs = vi.fn(async () => conflictedRevisions);
|
||||
const storeContent = vi.fn(async () => true);
|
||||
const isSuspended = vi.fn(() => false);
|
||||
const getActiveFilePath = vi.fn((): FilePathWithPrefix | undefined => path);
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const currentSettings = vi.fn(() => ({ syncAfterMerge: false }));
|
||||
const log = vi.fn();
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent,
|
||||
},
|
||||
localDatabase: () => ({ getDBEntry, findAllDocs }),
|
||||
confirm: {
|
||||
askSelectString,
|
||||
askInPopup,
|
||||
},
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
vault: { getActiveFilePath },
|
||||
appLifecycle: { isSuspended },
|
||||
conflict: { queueCheckFor, ensureAllProcessed, resolveByDeletingRevision },
|
||||
replication: {
|
||||
replicateUnattendedByEvent,
|
||||
},
|
||||
currentSettings,
|
||||
createDialogue: vi.fn(() => {
|
||||
constructed.value++;
|
||||
return {
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async () => dialogueResult),
|
||||
};
|
||||
}),
|
||||
log,
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return {
|
||||
askSelectString,
|
||||
askInPopup,
|
||||
constructed,
|
||||
context,
|
||||
dialogueResult: {
|
||||
get value() {
|
||||
return dialogueResult;
|
||||
},
|
||||
set value(value: unknown) {
|
||||
dialogueResult = value;
|
||||
},
|
||||
},
|
||||
findAllDocs,
|
||||
getDBEntry,
|
||||
getActiveFilePath,
|
||||
getConflictedRevs,
|
||||
ensureAllProcessed,
|
||||
currentSettings,
|
||||
isSuspended,
|
||||
log,
|
||||
operations,
|
||||
queueCheckFor,
|
||||
replicateUnattendedByEvent,
|
||||
resolveByDeletingRevision,
|
||||
storeContent,
|
||||
conflictedRevisions,
|
||||
};
|
||||
}
|
||||
|
||||
type ControlledDialogue = {
|
||||
readonly filename: FilePathWithPrefix;
|
||||
readonly open: ReturnType<typeof vi.fn>;
|
||||
readonly finish: (result: MergeDialogResult) => void;
|
||||
readonly waitForResult: () => Promise<MergeDialogResult>;
|
||||
};
|
||||
|
||||
function createControlledDialogueFactory(
|
||||
context: ReturnType<typeof createServiceContext>,
|
||||
dialogues: ControlledDialogue[]
|
||||
): ConflictResolveDialogueFactory {
|
||||
const createDialogue = vi.fn((filename: FilePathWithPrefix) => {
|
||||
let settle!: (result: MergeDialogResult) => void;
|
||||
let offConflictCancelled: (() => void) | undefined;
|
||||
const result = new Promise<MergeDialogResult>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const finish = (dialogueResult: MergeDialogResult) => {
|
||||
offConflictCancelled?.();
|
||||
offConflictCancelled = undefined;
|
||||
settle(dialogueResult);
|
||||
};
|
||||
const dialogue: ControlledDialogue = {
|
||||
filename,
|
||||
open: vi.fn(() => {
|
||||
offConflictCancelled = context.events.onEvent(EVENT_CONFLICT_CANCELLED, (cancelledFilename) => {
|
||||
if (cancelledFilename === filename) {
|
||||
finish(CANCELLED);
|
||||
}
|
||||
});
|
||||
}),
|
||||
finish,
|
||||
waitForResult: () => result,
|
||||
};
|
||||
dialogues.push(dialogue);
|
||||
return dialogue;
|
||||
});
|
||||
return createDialogue as unknown as ConflictResolveDialogueFactory;
|
||||
}
|
||||
|
||||
function createDialogueConcurrencyHarness() {
|
||||
const context = createServiceContext();
|
||||
const emitEvent = vi.spyOn(context.events, "emitEvent");
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const createDialogue = createControlledDialogueFactory(context, dialogues);
|
||||
const operations = createInteractiveConflictResolutionOperations({
|
||||
events: context.events,
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => ["2-right"]),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: () => ({
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
}),
|
||||
confirm: { askSelectString: vi.fn(), askInPopup: vi.fn() },
|
||||
path: { getPath: vi.fn() },
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict: {
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
currentSettings: () => ({ syncAfterMerge: false }),
|
||||
createDialogue: createDialogue as ConflictResolveDialogueFactory,
|
||||
log: vi.fn(),
|
||||
} as unknown as InteractiveConflictResolutionOperationsDependencies);
|
||||
return { context, createDialogue, dialogues, emitEvent, operations };
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution operations", () => {
|
||||
it("replaces an active same-file dialogue with only the newest waiting request", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
|
||||
const first = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
|
||||
const superseded = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
const replacement = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
|
||||
expect(fixture.emitEvent).toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, path);
|
||||
expect(fixture.dialogues[1].filename).toBe(path);
|
||||
expect(fixture.createDialogue).toHaveBeenCalledTimes(2);
|
||||
|
||||
fixture.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([first, superseded, replacement]);
|
||||
});
|
||||
|
||||
it("keeps a same-file replacement request when its cancellation refresh sees the active dialogue", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
const first = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const replacement = resolveByUserInteraction(path, conflict);
|
||||
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(2));
|
||||
expect(dialogues[1]?.filename).toBe(path);
|
||||
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await Promise.all([first, replacement]);
|
||||
});
|
||||
|
||||
it("keeps a different file waiting until the active dialogue finishes", async () => {
|
||||
const fixture = createDialogueConcurrencyHarness();
|
||||
const otherPath = "other.md" as FilePathWithPrefix;
|
||||
|
||||
const first = fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(1));
|
||||
const waiting = fixture.operations.resolveByUserInteraction(otherPath, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.dialogues).toHaveLength(1);
|
||||
expect(fixture.emitEvent).not.toHaveBeenCalledWith(EVENT_CONFLICT_CANCELLED, otherPath);
|
||||
|
||||
fixture.dialogues[0].finish(CANCELLED);
|
||||
await vi.waitFor(() => expect(fixture.dialogues).toHaveLength(2));
|
||||
expect(fixture.dialogues[1].filename).toBe(otherPath);
|
||||
fixture.dialogues[1].finish(CANCELLED);
|
||||
await Promise.all([first, waiting]);
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { constructed, operations } = createOperations();
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { constructed, dialogueResult, operations } = createOperations();
|
||||
dialogueResult.value = CANCELLED;
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { constructed, dialogueResult, ensureAllProcessed, operations, queueCheckFor } = createOperations();
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
await operations.requestConflictResolution(path);
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(queueCheckFor).toHaveBeenCalledOnce();
|
||||
expect(ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(constructed.value).toBe(2);
|
||||
expect(dialogueResult.value).toBe(POSTPONED);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { constructed, operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await operations.refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(constructed.value).toBe(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "cannot be read", result: false as const, refreshes: false },
|
||||
{ label: "has no conflicts", result: { _rev: "2-left", _conflicts: [] as string[] }, refreshes: true },
|
||||
])("exits safely when the live database $label after a user selection", async ({ result, refreshes }) => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue(result);
|
||||
const emitEvent = vi.spyOn(fixture.context.events, "emitEvent");
|
||||
|
||||
await expect(fixture.operations.resolveByUserInteraction(path, conflict)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
if (refreshes) {
|
||||
expect(fixture.getConflictedRevs).toHaveBeenCalledWith(path);
|
||||
expect(emitEvent).toHaveBeenCalledWith(EVENT_ON_UNRESOLVED_ERROR);
|
||||
} else {
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
expect(emitEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { operations } = createOperations();
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("returns no unresolved message when there is no active file", async () => {
|
||||
const { getActiveFilePath, getConflictedRevs, operations } = createOperations();
|
||||
getActiveFilePath.mockReturnValue(undefined);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
|
||||
expect(getConflictedRevs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a failed conflict inspection and returns no unresolved messages", async () => {
|
||||
const fixture = createOperations();
|
||||
const error = new Error("database unavailable");
|
||||
fixture.getConflictedRevs.mockRejectedValue(error);
|
||||
|
||||
await expect(fixture.operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
|
||||
expect(fixture.log).toHaveBeenCalledWith("Could not inspect the conflict state of note.md", expect.anything());
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, expect.anything());
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { operations } = createOperations(conflictedRevisions);
|
||||
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await operations.refreshConflictState(path);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await operations.refreshConflictState(path);
|
||||
await expect(operations.getActiveConflictMessages()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createOperations(conflictedRevisions);
|
||||
|
||||
await firstSession.operations.resolveByUserInteraction(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createOperations(conflictedRevisions);
|
||||
await expect(restartedSession.operations.getActiveConflictMessages()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.operations.resolveByUserInteraction(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(restartedSession.constructed.value).toBe(1);
|
||||
});
|
||||
|
||||
it("stores the exact concatenated diff before deleting a deterministically selected pair", async () => {
|
||||
const fixture = createOperations(["2-unrelated", "2-right"]);
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
await fixture.operations.resolveByUserInteraction(path, {
|
||||
...conflict,
|
||||
diff: [
|
||||
[0, "left"],
|
||||
[1, "\n"],
|
||||
[1, "right"],
|
||||
],
|
||||
});
|
||||
|
||||
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("does not replicate or requeue when concatenated revision deletion fails", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, {
|
||||
...conflict,
|
||||
diff: [
|
||||
[0, "left"],
|
||||
[1, "\nright"],
|
||||
],
|
||||
});
|
||||
|
||||
expect(fixture.storeContent).toHaveBeenCalledWith(path, "left\nright");
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const fixture = createOperations(["2-other"]);
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("does not delete a revision when concatenated content cannot be stored", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = LEAVE_TO_SUBSEQUENT;
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.storeContent.mockResolvedValue(false);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replicate or requeue when selected revision deletion fails", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.resolveByDeletingRevision.mockResolvedValue(MISSING_OR_ERROR);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unexpected dialogue result without changing revisions", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "unexpected-result";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.log).toHaveBeenCalledWith(
|
||||
`Merge: Something went wrong: ${path}, (unexpected-result)`,
|
||||
expect.anything()
|
||||
);
|
||||
expect(fixture.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replicates and requeues after a selected revision is resolved", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Selected");
|
||||
expect(fixture.replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
trigger: "merge",
|
||||
interaction: expect.anything(),
|
||||
});
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("requeues without replication while the app is suspended", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.dialogueResult.value = "2-right";
|
||||
fixture.getDBEntry.mockResolvedValue({ _rev: "2-left", _conflicts: ["2-right"] });
|
||||
fixture.currentSettings.mockReturnValue({ syncAfterMerge: true });
|
||||
fixture.isSuspended.mockReturnValue(true);
|
||||
|
||||
await fixture.operations.resolveByUserInteraction(path, conflict);
|
||||
|
||||
expect(fixture.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(fixture.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
fixture.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await fixture.operations.allConflictCheck();
|
||||
|
||||
expect(fixture.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(fixture.log).not.toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const fixture = createOperations();
|
||||
|
||||
await fixture.operations.pickFileForResolve();
|
||||
|
||||
expect(fixture.askSelectString).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalledWith("There are no conflicted documents", expect.anything());
|
||||
});
|
||||
|
||||
it("sorts multiple picker candidates newest first and returns safely on cancellation", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs.mockReturnValue(
|
||||
documents([
|
||||
{ _id: "ordinary-id", path: "ordinary.md", mtime: 40 },
|
||||
{ _id: "old-id", path: "old.md", _conflicts: ["2-old"], mtime: 10 },
|
||||
{ _id: "new-id", path: "new.md", _conflicts: ["2-new"], mtime: 30 },
|
||||
{ _id: "middle-id", path: "middle.md", _conflicts: ["2-middle"], mtime: 20 },
|
||||
])
|
||||
);
|
||||
fixture.askSelectString.mockResolvedValue("");
|
||||
|
||||
await expect(fixture.operations.pickFileForResolve()).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", [
|
||||
"new.md",
|
||||
"middle.md",
|
||||
"old.md",
|
||||
]);
|
||||
expect(fixture.queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(fixture.ensureAllProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports conflicted documents and wires the startup popup action", async () => {
|
||||
const fixture = createOperations();
|
||||
fixture.findAllDocs.mockImplementation(() =>
|
||||
documents([
|
||||
{ _id: "ordinary-id", path: "ordinary.md", mtime: 30 },
|
||||
{ _id: "first-id", path: "first.md", _conflicts: ["2-first"], mtime: 10 },
|
||||
{ _id: "second-id", path: "second.md", _conflicts: ["2-second"], mtime: 20 },
|
||||
])
|
||||
);
|
||||
|
||||
await expect(fixture.operations.scanStartupIssues()).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askInPopup).toHaveBeenCalledWith(
|
||||
"conflicting-detected-on-safety",
|
||||
expect.stringContaining("Some files have been left conflicted!"),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(fixture.log).toHaveBeenCalledWith("Conflicted: first.md");
|
||||
expect(fixture.log).toHaveBeenCalledWith("Conflicted: second.md");
|
||||
expect(fixture.log).not.toHaveBeenCalledWith("Conflicted: ordinary.md");
|
||||
|
||||
const popupAction = fixture.askInPopup.mock.calls[0]?.[2] as (anchor: HTMLAnchorElement) => void;
|
||||
const addEventListener = vi.fn();
|
||||
const anchor = { text: "", addEventListener } as unknown as HTMLAnchorElement;
|
||||
popupAction(anchor);
|
||||
|
||||
expect(anchor.text).toBe("HERE");
|
||||
expect(addEventListener).toHaveBeenCalledWith("click", expect.any(Function));
|
||||
|
||||
const clickHandler = addEventListener.mock.calls[0]?.[1] as () => void;
|
||||
clickHandler();
|
||||
await vi.waitFor(() =>
|
||||
expect(fixture.askSelectString).toHaveBeenCalledWith("File to resolve conflict", ["second.md", "first.md"])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createFeatureHarness(
|
||||
createDialogueForContext?: (context: ReturnType<typeof createServiceContext>) => ConflictResolveDialogueFactory
|
||||
) {
|
||||
const context = createServiceContext();
|
||||
type FeatureLocalDatabase = {
|
||||
getDBEntry: (...args: unknown[]) => Promise<unknown>;
|
||||
findAllDocs: (...args: unknown[]) => AsyncGenerator<unknown>;
|
||||
};
|
||||
const initialLocalDatabase = {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
};
|
||||
let activeLocalDatabase: FeatureLocalDatabase = initialLocalDatabase;
|
||||
const getLocalDatabase = vi.fn(() => activeLocalDatabase);
|
||||
const replaceLocalDatabase = (replacement: FeatureLocalDatabase) => {
|
||||
activeLocalDatabase = replacement;
|
||||
};
|
||||
const database = {} as { readonly localDatabase: ReturnType<typeof getLocalDatabase> };
|
||||
Object.defineProperty(database, "localDatabase", { get: getLocalDatabase });
|
||||
const handlers = {
|
||||
initialise: undefined as undefined | (() => Promise<boolean>),
|
||||
onUnload: undefined as undefined | (() => Promise<boolean>),
|
||||
scanning: undefined as undefined | (() => Promise<boolean>),
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
resolveByUserInteraction: undefined as
|
||||
| undefined
|
||||
| ((filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>),
|
||||
};
|
||||
const getConflictedRevs = vi.fn(async () => ["2-right"]);
|
||||
const services = {
|
||||
API: {
|
||||
addCommand: vi.fn(),
|
||||
addLog: vi.fn(),
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async () => ""),
|
||||
askInPopup: vi.fn(),
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.initialise = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.scanning = handler;
|
||||
}),
|
||||
},
|
||||
onUnload: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
handlers.onUnload = handler;
|
||||
}),
|
||||
},
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: {
|
||||
addHandler: vi.fn(
|
||||
(handler: (filename: FilePathWithPrefix, result: diff_result) => Promise<boolean>) => {
|
||||
handlers.resolveByUserInteraction = handler;
|
||||
}
|
||||
),
|
||||
},
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
},
|
||||
replication: {
|
||||
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
database,
|
||||
setting: { currentSettings: vi.fn(() => ({ ...DEFAULT_SETTINGS, syncAfterMerge: false })) },
|
||||
context,
|
||||
};
|
||||
const serviceModules = {
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs,
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
};
|
||||
const createDialogue =
|
||||
createDialogueForContext?.(context) ??
|
||||
vi.fn(() => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async (): Promise<typeof POSTPONED> => POSTPONED),
|
||||
}));
|
||||
|
||||
useInteractiveConflictResolutionFeature({ services, serviceModules } as never, createDialogue);
|
||||
return {
|
||||
context,
|
||||
createDialogue,
|
||||
getConflictedRevs,
|
||||
getLocalDatabase,
|
||||
handlers,
|
||||
initialLocalDatabase,
|
||||
replaceLocalDatabase,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
describe("interactive conflict resolution feature composition", () => {
|
||||
it("registers lifecycle, command, conflict, and cancellation handlers", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
|
||||
expect(fixture.services.appLifecycle.onScanningStartupIssues.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.onInitialise.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.conflict.resolveByUserInteraction.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledOnce();
|
||||
expect(fixture.getLocalDatabase).not.toHaveBeenCalled();
|
||||
|
||||
await fixture.handlers.initialise?.();
|
||||
expect(fixture.services.API.addCommand).toHaveBeenCalledTimes(3);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ id: "livesync-checkdoc-conflicted", name: "Resolve if conflicted." })
|
||||
);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ id: "livesync-conflictcheck", name: "Pick a file to resolve conflict" })
|
||||
);
|
||||
expect(fixture.services.API.addCommand).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ id: "livesync-all-conflictcheck", name: "Resolve all conflicted files" })
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes unresolved state through the host context event channel and disposes the listener", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
await vi.waitFor(() => expect(fixture.getConflictedRevs).toHaveBeenCalledOnce());
|
||||
|
||||
await fixture.handlers.onUnload?.();
|
||||
fixture.getConflictedRevs.mockClear();
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, path);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.getConflictedRevs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the active dialogue and drops waiting dialogues on unload", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
const active = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const waiting = resolveByUserInteraction("waiting.md" as FilePathWithPrefix, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
let completed = false;
|
||||
const allResolutions = Promise.all([active, waiting]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
const completedOnUnload = completed;
|
||||
|
||||
if (!completedOnUnload) {
|
||||
dialogues[0]?.finish(CANCELLED);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await allResolutions;
|
||||
}
|
||||
|
||||
expect(completedOnUnload).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
it("rejects a resolution request after unload without opening a dialogue", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
|
||||
await fixture.handlers.onUnload?.();
|
||||
await expect(resolveByUserInteraction(path, conflict)).resolves.toBe(false);
|
||||
|
||||
expect(dialogues).toHaveLength(0);
|
||||
expect(fixture.createDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a waiting dialogue when its conflict is resolved elsewhere", async () => {
|
||||
const dialogues: ControlledDialogue[] = [];
|
||||
const fixture = createFeatureHarness((context) => createControlledDialogueFactory(context, dialogues));
|
||||
const resolveByUserInteraction = fixture.handlers.resolveByUserInteraction!;
|
||||
const waitingPath = "resolved-while-waiting.md" as FilePathWithPrefix;
|
||||
|
||||
const active = resolveByUserInteraction(path, conflict);
|
||||
await vi.waitFor(() => expect(dialogues).toHaveLength(1));
|
||||
const waiting = resolveByUserInteraction(waitingPath, conflict);
|
||||
await Promise.resolve();
|
||||
|
||||
fixture.context.events.emitEvent(EVENT_CONFLICT_CANCELLED, waitingPath);
|
||||
dialogues[0].finish(CANCELLED);
|
||||
let completed = false;
|
||||
const allResolutions = Promise.all([active, waiting]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
const completedWithoutOpening = completed;
|
||||
|
||||
if (!completedWithoutOpening) {
|
||||
dialogues[1]?.finish(CANCELLED);
|
||||
await allResolutions;
|
||||
}
|
||||
|
||||
expect(completedWithoutOpening).toBe(true);
|
||||
expect(dialogues).toHaveLength(1);
|
||||
await fixture.handlers.onUnload?.();
|
||||
});
|
||||
|
||||
it("uses the replacement local database for operations after a reset", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
const replacementLocalDatabase = {
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
};
|
||||
|
||||
await fixture.handlers.scanning?.();
|
||||
fixture.replaceLocalDatabase(replacementLocalDatabase);
|
||||
await fixture.handlers.scanning?.();
|
||||
|
||||
expect(fixture.initialLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
|
||||
expect(replacementLocalDatabase.findAllDocs).toHaveBeenCalledOnce();
|
||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("routes registered command callbacks to conflict operations", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
await fixture.handlers.initialise?.();
|
||||
const commands = fixture.services.API.addCommand.mock.calls.map(
|
||||
([command]) => command as Record<string, unknown>
|
||||
);
|
||||
|
||||
(commands[0].editorCallback as (editor: unknown, view: unknown) => void)({}, { file: { path } });
|
||||
await vi.waitFor(() => expect(fixture.services.conflict.ensureAllProcessed).toHaveBeenCalledOnce());
|
||||
await (commands[1].callback as () => Promise<void>)();
|
||||
await (commands[2].callback as () => Promise<void>)();
|
||||
|
||||
expect(fixture.services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(fixture.getLocalDatabase).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reads the host's current sync setting after a composed resolution", async () => {
|
||||
const fixture = createFeatureHarness(() => () => ({
|
||||
open: vi.fn(),
|
||||
waitForResult: vi.fn(async (): Promise<MergeDialogResult> => "2-right"),
|
||||
}));
|
||||
fixture.replaceLocalDatabase({
|
||||
getDBEntry: vi.fn(async () => ({ _rev: "2-left", _conflicts: ["2-right"] })),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
});
|
||||
fixture.services.setting.currentSettings.mockReturnValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
syncAfterMerge: true,
|
||||
});
|
||||
|
||||
await fixture.handlers.resolveByUserInteraction?.(path, conflict);
|
||||
|
||||
expect(fixture.services.setting.currentSettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.services.replication.replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does nothing when the editor command has no active file", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
await fixture.handlers.initialise?.();
|
||||
const command = fixture.services.API.addCommand.mock.calls[0]?.[0] as {
|
||||
editorCallback: (editor: unknown, view: { file: null }) => void;
|
||||
};
|
||||
|
||||
command.editorCallback({}, { file: null });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fixture.services.conflict.queueCheckFor).not.toHaveBeenCalled();
|
||||
expect(fixture.services.conflict.ensureAllProcessed).not.toHaveBeenCalled();
|
||||
expect(fixture.createDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a failed startup scan without escaping the lifecycle handler", async () => {
|
||||
const fixture = createFeatureHarness();
|
||||
async function* failedDocuments() {
|
||||
throw new Error("database unavailable");
|
||||
}
|
||||
fixture.replaceLocalDatabase({
|
||||
getDBEntry: vi.fn(async () => false),
|
||||
findAllDocs: vi.fn(() => failedDocuments()),
|
||||
});
|
||||
|
||||
await expect(fixture.handlers.scanning?.()).resolves.toBe(false);
|
||||
expect(fixture.services.API.addLog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
MISSING_OR_ERROR,
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type {
|
||||
IAppLifecycleService,
|
||||
IConflictService,
|
||||
IPathService,
|
||||
IReplicationService,
|
||||
IVaultService,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import {
|
||||
POSTPONED,
|
||||
type ConflictResolveDialogue,
|
||||
type ConflictResolveDialogueFactory,
|
||||
type MergeDialogResult,
|
||||
} from "./types";
|
||||
|
||||
export interface InteractiveConflictResolutionOperationsDependencies {
|
||||
readonly events: Pick<LiveSyncEventHub, "emitEvent">;
|
||||
readonly databaseFileAccess: Pick<DatabaseFileAccess, "getConflictedRevs" | "storeContent">;
|
||||
readonly localDatabase: () => Pick<LiveSyncLocalDB, "getDBEntry" | "findAllDocs">;
|
||||
readonly confirm: Pick<Confirm, "askSelectString" | "askInPopup">;
|
||||
readonly path: Pick<IPathService, "getPath">;
|
||||
readonly vault: Pick<IVaultService, "getActiveFilePath">;
|
||||
readonly appLifecycle: Pick<IAppLifecycleService, "isSuspended">;
|
||||
readonly conflict: Pick<IConflictService, "queueCheckFor" | "ensureAllProcessed" | "resolveByDeletingRevision">;
|
||||
readonly replication: Pick<IReplicationService, "replicateUnattendedByEvent">;
|
||||
readonly currentSettings: () => Pick<ObsidianLiveSyncSettings, "syncAfterMerge">;
|
||||
readonly createDialogue: ConflictResolveDialogueFactory;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
export interface InteractiveConflictResolutionOperations {
|
||||
readonly dispose: () => void;
|
||||
readonly invalidateWaitingResolution: (filename: FilePathWithPrefix) => void;
|
||||
readonly getActiveConflictMessages: () => Promise<string[]>;
|
||||
readonly refreshConflictState: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly requestConflictResolution: (filename: FilePathWithPrefix) => Promise<void>;
|
||||
readonly resolveByUserInteraction: (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
) => Promise<boolean>;
|
||||
readonly allConflictCheck: () => Promise<void>;
|
||||
readonly pickFileForResolve: (notifyIfEmpty?: boolean) => Promise<boolean>;
|
||||
readonly scanStartupIssues: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createInteractiveConflictResolutionOperations(
|
||||
dependencies: InteractiveConflictResolutionOperationsDependencies
|
||||
): InteractiveConflictResolutionOperations {
|
||||
// This state deliberately belongs to one feature composition. It must not
|
||||
// survive a plug-in unload or be shared with another host context.
|
||||
const postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
const dialogueSerialisationKey = Symbol("conflict-resolve-ui");
|
||||
const latestRequestByFilename = new Map<FilePathWithPrefix, number>();
|
||||
let nextRequestId = 0;
|
||||
let activeDialogue: { filename: FilePathWithPrefix; dialogue: ConflictResolveDialogue } | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const invalidateWaitingResolution = (filename: FilePathWithPrefix): void => {
|
||||
// An active dialogue consumes this event itself. Removing its request
|
||||
// here would also remove a same-path replacement which emitted the
|
||||
// event to close that active dialogue. A non-active request is stale
|
||||
// and must not open after an external resolution.
|
||||
if (activeDialogue?.filename === filename) return;
|
||||
latestRequestByFilename.delete(filename);
|
||||
};
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
latestRequestByFilename.clear();
|
||||
postponedConflictEpisodes.clear();
|
||||
const activeFilename = activeDialogue?.filename;
|
||||
if (activeFilename !== undefined) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, activeFilename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getConflictVersionCount = async (filename: FilePathWithPrefix): Promise<number | undefined> => {
|
||||
try {
|
||||
const conflictCount = (await dependencies.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
dependencies.log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveConflictMessages = async (): Promise<string[]> => {
|
||||
const filename = dependencies.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const refreshConflictState = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
if ((await getConflictVersionCount(filename)) === 0) {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
};
|
||||
|
||||
const requestConflictResolution = async (filename: FilePathWithPrefix): Promise<void> => {
|
||||
postponedConflictEpisodes.delete(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
await dependencies.conflict.ensureAllProcessed();
|
||||
};
|
||||
|
||||
const resolveByUserInteraction = async (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
): Promise<boolean> => {
|
||||
if (disposed) return false;
|
||||
const requestId = ++nextRequestId;
|
||||
if (activeDialogue?.filename === filename) {
|
||||
dependencies.events.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
latestRequestByFilename.set(filename, requestId);
|
||||
|
||||
// UI for resolving different files should proceed one-by-one. A newer
|
||||
// request for the active file replaces its dialogue instead of waiting
|
||||
// behind a comparison which is already stale.
|
||||
return await serialized(dialogueSerialisationKey, async () => {
|
||||
if (disposed || latestRequestByFilename.get(filename) !== requestId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (postponedConflictEpisodes.has(filename)) {
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
dependencies.log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialogue = dependencies.createDialogue(filename, conflictCheckResult);
|
||||
activeDialogue = { filename, dialogue };
|
||||
let selected: MergeDialogResult;
|
||||
try {
|
||||
dialogue.open();
|
||||
selected = await dialogue.waitForResult();
|
||||
} finally {
|
||||
if (activeDialogue?.dialogue === dialogue) {
|
||||
activeDialogue = undefined;
|
||||
}
|
||||
}
|
||||
if (selected === POSTPONED) {
|
||||
postponedConflictEpisodes.add(filename);
|
||||
dependencies.events.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
dependencies.log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
dependencies.log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
const testDoc = await dependencies
|
||||
.localDatabase()
|
||||
.getDBEntry(filename, { conflicts: true }, false, true, true);
|
||||
if (testDoc === false) {
|
||||
dependencies.log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
dependencies.log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
dependencies.log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await refreshConflictState(filename);
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
// const toKeep = conflictCheckResult.left.rev != toDelete ? conflictCheckResult.left.rev : conflictCheckResult.right.rev;
|
||||
if (toDelete === LEAVE_TO_SUBSEQUENT) {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await dependencies.databaseFileAccess.storeContent(filename, p))) {
|
||||
dependencies.log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// 2. As usual, delete the conflicted revision and if there are no conflicts, write the resolved content to the storage.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, delRev, "UI Concatenated")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
dependencies.log(
|
||||
`Concatenated saved, but cannot delete conflicted revisions: ${filename}, (${displayRev(delRev)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await dependencies.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
MISSING_OR_ERROR
|
||||
) {
|
||||
dependencies.log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
dependencies.log(
|
||||
`Merge: Something went wrong: ${filename}, (${String(toDelete)})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
// So we have to run replication if configured.
|
||||
// TODO: Make this is as a event request
|
||||
if (dependencies.currentSettings().syncAfterMerge && !dependencies.appLifecycle.isSuspended()) {
|
||||
await dependencies.replication.replicateUnattendedByEvent({
|
||||
trigger: "merge",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
}
|
||||
// And, check it again.
|
||||
await dependencies.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
} finally {
|
||||
if (latestRequestByFilename.get(filename) === requestId) {
|
||||
latestRequestByFilename.delete(filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const pickFileForResolve = async (notifyIfEmpty = true): Promise<boolean> => {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({
|
||||
id: doc._id,
|
||||
path: dependencies.path.getPath(doc),
|
||||
dispPath: stripAllPrefixes(dependencies.path.getPath(doc)),
|
||||
mtime: doc.mtime,
|
||||
});
|
||||
}
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
if (notifyIfEmpty) {
|
||||
dependencies.log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await dependencies.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const allConflictCheck = async (): Promise<void> => {
|
||||
let notifyIfEmpty = true;
|
||||
while (await pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scanStartupIssues = async (): Promise<boolean> => {
|
||||
const notes: { path: string; mtime: number }[] = [];
|
||||
dependencies.log(`Checking conflicted files`, LOG_LEVEL_VERBOSE);
|
||||
try {
|
||||
for await (const doc of dependencies.localDatabase().findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
notes.push({ path: dependencies.path.getPath(doc), mtime: doc.mtime });
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
dependencies.confirm.askInPopup(
|
||||
`conflicting-detected-on-safety`,
|
||||
`Some files have been left conflicted! Press {HERE} to resolve them, or you can do it later by "Pick a file to resolve conflict`,
|
||||
(anchor) => {
|
||||
anchor.text = "HERE";
|
||||
anchor.addEventListener("click", () => {
|
||||
fireAndForget(() => allConflictCheck());
|
||||
});
|
||||
}
|
||||
);
|
||||
dependencies.log(
|
||||
`Some files have been left conflicted! Please resolve them by "Pick a file to resolve conflict". The list is written in the log.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
for (const note of notes) {
|
||||
dependencies.log(`Conflicted: ${note.path}`);
|
||||
}
|
||||
} else {
|
||||
dependencies.log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
dependencies.log(error, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
dispose,
|
||||
invalidateWaitingResolution,
|
||||
getActiveConflictMessages,
|
||||
refreshConflictState,
|
||||
requestConflictResolution,
|
||||
resolveByUserInteraction,
|
||||
allConflictCheck,
|
||||
pickFileForResolve,
|
||||
scanStartupIssues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export interface ConflictResolveDialogue {
|
||||
open(): void;
|
||||
waitForResult(): Promise<MergeDialogResult>;
|
||||
}
|
||||
|
||||
export type ConflictResolveDialogueFactory = (
|
||||
filename: FilePathWithPrefix,
|
||||
conflictCheckResult: diff_result
|
||||
) => ConflictResolveDialogue;
|
||||
+22
-18
@@ -1,27 +1,35 @@
|
||||
import { addIcon } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
// Obsidian specific menu commands.
|
||||
export class ModuleObsidianMenu extends AbstractModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
// UI
|
||||
addIcon(
|
||||
"replicate",
|
||||
`<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
|
||||
export type ObsidianReplicationRibbonHost = NecessaryServices<"API" | "appLifecycle" | "replication", never>;
|
||||
|
||||
/** The established SVG used by the Obsidian replication ribbon action. */
|
||||
const REPLICATE_ICON_SVG = `<g transform="matrix(1.15 0 0 1.15 -8.31 -9.52)" fill="currentColor" fill-rule="evenodd">
|
||||
<path d="m85 22.2c-0.799-4.74-4.99-8.37-9.88-8.37-0.499 0-1.1 0.101-1.6 0.101-2.4-3.03-6.09-4.94-10.3-4.94-6.09 0-11.2 4.14-12.8 9.79-5.59 1.11-9.78 6.05-9.78 12 0 6.76 5.39 12.2 12 12.2h29.9c5.79 0 10.1-4.74 10.1-10.6 0-4.84-3.29-8.88-7.68-10.2zm-2.99 14.7h-29.5c-2.3-0.202-4.29-1.51-5.29-3.53-0.899-2.12-0.699-4.54 0.698-6.46 1.2-1.61 2.99-2.52 4.89-2.52 0.299 0 0.698 0 0.998 0.101l1.8 0.303v-2.02c0-3.63 2.4-6.76 5.89-7.57 0.599-0.101 1.2-0.202 1.8-0.202 2.89 0 5.49 1.62 6.79 4.24l0.598 1.21 1.3-0.504c0.599-0.202 1.3-0.303 2-0.303 1.3 0 2.5 0.404 3.59 1.11 1.6 1.21 2.6 3.13 2.6 5.15v1.61h2c2.6 0 4.69 2.12 4.69 4.74-0.099 2.52-2.2 4.64-4.79 4.64z"/>
|
||||
<path d="m53.2 49.2h-41.6c-1.8 0-3.2 1.4-3.2 3.2v28.6c0 1.8 1.4 3.2 3.2 3.2h15.8v4h-7v6h24v-6h-7v-4h15.8c1.8 0 3.2-1.4 3.2-3.2v-28.6c0-1.8-1.4-3.2-3.2-3.2zm-2.8 29h-36v-23h36z"/>
|
||||
<path d="m73 49.2c1.02 1.29 1.53 2.97 1.53 4.56 0 2.97-1.74 5.65-4.39 7.04v-4.06l-7.46 7.33 7.46 7.14v-4.06c7.66-1.98 12.2-9.61 10-17-0.102-0.297-0.205-0.595-0.307-0.892z"/>
|
||||
<path d="m24.1 43c-0.817-0.991-1.53-2.97-1.53-4.56 0-2.97 1.74-5.65 4.39-7.04v4.06l7.46-7.33-7.46-7.14v4.06c-7.66 1.98-12.2 9.61-10 17 0.102 0.297 0.205 0.595 0.307 0.892z"/>
|
||||
</g>`
|
||||
);
|
||||
</g>`;
|
||||
|
||||
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await this.services.replication.replicateUserInitiated({
|
||||
/**
|
||||
* Register the Obsidian-only replication ribbon action.
|
||||
*
|
||||
* The icon and ribbon element are intentionally kept out of the generic Basic
|
||||
* commands feature; other hosts can compose the latter without Obsidian UI.
|
||||
*/
|
||||
export function useObsidianReplicationRibbonFeature(host: ObsidianReplicationRibbonHost): void {
|
||||
const { services } = host;
|
||||
|
||||
services.appLifecycle.onInitialise.addHandler(() => {
|
||||
addIcon("replicate", REPLICATE_ICON_SVG);
|
||||
|
||||
services.API.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
|
||||
await services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -29,9 +37,5 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
}).addClass("livesync-ribbon-replicate");
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const addIcon = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/deps.ts", () => ({ addIcon }));
|
||||
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { useObsidianReplicationRibbonFeature, type ObsidianReplicationRibbonHost } from "./obsidianReplicationRibbon";
|
||||
|
||||
describe("useObsidianReplicationRibbonFeature", () => {
|
||||
it("registers the established icon and ribbon callback during initialisation", async () => {
|
||||
let initialise: (() => Promise<unknown>) | undefined;
|
||||
let ribbonCallback: (() => Promise<void>) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const addRibbonIcon = vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
|
||||
ribbonCallback = callback;
|
||||
return { addClass } as unknown as HTMLElement;
|
||||
});
|
||||
const host = {
|
||||
services: {
|
||||
API: { addRibbonIcon },
|
||||
appLifecycle: {
|
||||
onInitialise: {
|
||||
addHandler: vi.fn((handler: () => Promise<unknown>) => {
|
||||
initialise = handler;
|
||||
}),
|
||||
},
|
||||
},
|
||||
replication: { replicateUserInitiated },
|
||||
},
|
||||
} as unknown as ObsidianReplicationRibbonHost;
|
||||
|
||||
useObsidianReplicationRibbonFeature(host);
|
||||
|
||||
expect(addIcon).not.toHaveBeenCalled();
|
||||
expect(addRibbonIcon).not.toHaveBeenCalled();
|
||||
|
||||
await expect(initialise?.()).resolves.toBe(true);
|
||||
|
||||
expect(addIcon).toHaveBeenCalledWith("replicate", expect.any(String));
|
||||
expect(addIcon).toHaveBeenCalledWith("replicate", expect.stringContaining("c-7.66 1.98-12.2 9.61-10 17"));
|
||||
expect(addRibbonIcon).toHaveBeenCalledWith(
|
||||
"replicate",
|
||||
$msg("moduleObsidianMenu.replicate"),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
|
||||
|
||||
await ribbonCallback?.();
|
||||
expect(replicateUserInitiated).toHaveBeenCalledWith({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,10 @@ const taskMocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
||||
|
||||
import { ModuleConflictResolver } from "@/modules/coreFeatures/ModuleConflictResolver";
|
||||
import {
|
||||
createConflictResolutionOperations,
|
||||
type ConflictResolutionOperationsDependencies,
|
||||
} from "@/serviceFeatures/conflictResolution";
|
||||
import { ModuleObsidianEvents } from "@/modules/essentialObsidian/ModuleObsidianEvents";
|
||||
import {
|
||||
createReplicationSchedulingContext,
|
||||
@@ -224,18 +227,34 @@ describe("automatic replication triggers while P2P is active", () => {
|
||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
||||
const queueCheckFor = vi.fn(async () => undefined);
|
||||
const path = "merged.md" as FilePathWithPrefix;
|
||||
const module = {
|
||||
settings: p2pSettings({ syncAfterMerge: true }),
|
||||
services: {
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
conflict: { queueCheckFor },
|
||||
replication: { replicateUnattendedByEvent },
|
||||
const operations = createConflictResolutionOperations({
|
||||
events: { emitEvent: vi.fn() },
|
||||
databaseFileAccess: {
|
||||
fetchEntryMeta: vi.fn(),
|
||||
getConflictedRevs: vi.fn(async () => []),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
checkConflictAndPerformAutoMerge: vi.fn(async () => AUTO_MERGED),
|
||||
_log: vi.fn(),
|
||||
};
|
||||
fileHandler: {
|
||||
deleteRevisionFromDB: vi.fn(async () => true),
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
},
|
||||
localDatabase: () => ({
|
||||
tryAutoMerge: vi.fn(async () => ({ ok: AUTO_MERGED })),
|
||||
}),
|
||||
conflict: {
|
||||
queueCheckFor,
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
resolveByUserInteraction: vi.fn(async () => false),
|
||||
},
|
||||
replication: { replicateUnattendedByEvent },
|
||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
||||
vault: { getActiveFilePath: vi.fn(() => undefined) },
|
||||
storageAccess: { getFileNames: vi.fn(async () => []) },
|
||||
currentSettings: () => p2pSettings({ syncAfterMerge: true }),
|
||||
log: vi.fn(),
|
||||
} as unknown as ConflictResolutionOperationsDependencies);
|
||||
|
||||
await (ModuleConflictResolver.prototype as any)._resolveConflict.call(module, path);
|
||||
await operations.resolve(path);
|
||||
|
||||
expect(replicateUnattendedByEvent).toHaveBeenCalledOnce();
|
||||
expect(replicateUnattendedByEvent).toHaveBeenCalledWith({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { disableLegacyBulkChunkPreSend } from "@/common/compatibilitySettings";
|
||||
import type { LegacyBulkSendSettings } from "./types";
|
||||
|
||||
/** Collaborators required to persist the obsolete bulk-send setting migration. */
|
||||
export interface BulkSettingMigrationDependencies {
|
||||
readonly settings: LegacyBulkSendSettings;
|
||||
readonly log: LogFunction;
|
||||
readonly saveSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the removed automatic bulk chunk pre-send setting, retaining the
|
||||
* former notice text and persistence boundary.
|
||||
*/
|
||||
export async function migrateBulkSendSetting(dependencies: BulkSettingMigrationDependencies): Promise<void> {
|
||||
if (disableLegacyBulkChunkPreSend(dependencies.settings)) {
|
||||
dependencies.log($msg("moduleMigration.logBulkSendCorrupted"), LOG_LEVEL_NOTICE);
|
||||
await dependencies.saveSettings();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { migrateBulkSendSetting, type BulkSettingMigrationDependencies } from "./bulkSettingMigration";
|
||||
|
||||
function createDependencies(settings: { sendChunksBulk: boolean; sendChunksBulkMaxSize: number }) {
|
||||
const dependencies: BulkSettingMigrationDependencies = {
|
||||
settings,
|
||||
log: vi.fn(),
|
||||
saveSettings: vi.fn(async () => undefined),
|
||||
};
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
describe("migrateBulkSendSetting", () => {
|
||||
it("disables and persists an enabled obsolete bulk-send setting", async () => {
|
||||
const dependencies = createDependencies({ sendChunksBulk: true, sendChunksBulkMaxSize: 16 });
|
||||
|
||||
await migrateBulkSendSetting(dependencies);
|
||||
|
||||
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
expect(dependencies.log).toHaveBeenCalledWith(expect.any(String), expect.anything());
|
||||
expect(dependencies.saveSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not persist an already disabled obsolete setting", async () => {
|
||||
const dependencies = createDependencies({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
|
||||
await migrateBulkSendSetting(dependencies);
|
||||
|
||||
expect(dependencies.settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 16 });
|
||||
expect(dependencies.log).not.toHaveBeenCalled();
|
||||
expect(dependencies.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
type LOG_LEVEL,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
/** Focused collaborators for checking and recovering insecure chunks. */
|
||||
export interface CompromisedChunksDependencies {
|
||||
readonly settings: Pick<ObsidianLiveSyncSettings, "encrypt">;
|
||||
readonly localDatabase: {
|
||||
readonly localDatabase: Parameters<typeof countCompromisedChunks>[0];
|
||||
};
|
||||
readonly isOnline: boolean | (() => boolean);
|
||||
readonly getActiveReplicator: () => object | undefined;
|
||||
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
|
||||
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
|
||||
readonly performRestart: () => void;
|
||||
readonly log: (message: unknown, level?: LOG_LEVEL) => void;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function readOnline(value: boolean | (() => boolean)): boolean {
|
||||
return typeof value === "function" ? value() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check local and active-remote databases for insecure chunks and apply the
|
||||
* former rebuild, fetch, or dismiss dialogue semantics.
|
||||
*/
|
||||
export async function checkCompromisedChunks(dependencies: CompromisedChunksDependencies): Promise<boolean> {
|
||||
Logger(`Checking for compromised chunks...`, LOG_LEVEL_VERBOSE);
|
||||
if (!dependencies.settings.encrypt) {
|
||||
// If not encrypted, we do not need to check for compromised chunks.
|
||||
return true;
|
||||
}
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(dependencies.localDatabase.localDatabase);
|
||||
const remote = dependencies.getActiveReplicator();
|
||||
const remoteCompromised =
|
||||
readOnline(dependencies.isOnline) && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in remote database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (remoteCompromised === 0 && localCompromised === 0) {
|
||||
return true;
|
||||
}
|
||||
Logger(`Found compromised chunks : ${localCompromised} in local, ${remoteCompromised} in remote`, LOG_LEVEL_NOTICE);
|
||||
const title = $msg("moduleMigration.insecureChunkExist.title");
|
||||
const msg = $msg("moduleMigration.insecureChunkExist.message");
|
||||
const REBUILD = $msg("moduleMigration.insecureChunkExist.buttons.rebuild");
|
||||
const FETCH = $msg("moduleMigration.insecureChunkExist.buttons.fetch");
|
||||
const DISMISS = $msg("moduleMigration.insecureChunkExist.buttons.later");
|
||||
const buttons = [REBUILD, FETCH, DISMISS];
|
||||
if (remoteCompromised != 0) {
|
||||
buttons.splice(buttons.indexOf(FETCH), 1);
|
||||
}
|
||||
const result = await dependencies.confirm.askSelectStringDialogue(msg, buttons, {
|
||||
title,
|
||||
defaultAction: DISMISS,
|
||||
timeout: 0,
|
||||
});
|
||||
if (result === REBUILD) {
|
||||
// Rebuild the database
|
||||
await dependencies.rebuilder.scheduleRebuild();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else if (result === FETCH) {
|
||||
// Fetch the latest data from remote
|
||||
await dependencies.rebuilder.scheduleFetch();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else {
|
||||
// User chose to dismiss the issue
|
||||
dependencies.log($msg("moduleMigration.insecureChunkExist.laterMessage"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import { checkCompromisedChunks, type CompromisedChunksDependencies } from "./compromisedChunks";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
|
||||
countCompromisedChunks: vi.fn(),
|
||||
}));
|
||||
|
||||
function selectButton(index: number) {
|
||||
return async (...args: unknown[]): Promise<string | false> => {
|
||||
const buttons = args[1] as readonly string[];
|
||||
return buttons[index] ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies() {
|
||||
const askSelectStringDialogue = vi.fn(selectButton(2));
|
||||
const scheduleRebuild = vi.fn(async () => true);
|
||||
const scheduleFetch = vi.fn(async () => true);
|
||||
const performRestart = vi.fn();
|
||||
const getActiveReplicator = vi.fn((): object | undefined => undefined);
|
||||
const log = vi.fn();
|
||||
const dependencies: CompromisedChunksDependencies = {
|
||||
settings: { encrypt: true },
|
||||
localDatabase: { localDatabase: {} as never },
|
||||
isOnline: true,
|
||||
getActiveReplicator,
|
||||
confirm: { askSelectStringDialogue },
|
||||
rebuilder: { scheduleRebuild, scheduleFetch },
|
||||
performRestart,
|
||||
log,
|
||||
};
|
||||
return {
|
||||
askSelectStringDialogue,
|
||||
dependencies,
|
||||
getActiveReplicator,
|
||||
log,
|
||||
performRestart,
|
||||
scheduleFetch,
|
||||
scheduleRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkCompromisedChunks", () => {
|
||||
it("skips the database scan when encryption is disabled", async () => {
|
||||
const fixture = createDependencies();
|
||||
fixture.dependencies.settings.encrypt = false;
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(countCompromisedChunks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows start-up when local and active remote databases contain no compromised chunks", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when local chunk inspection fails", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(false);
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits when the active remote chunk inspection fails", async () => {
|
||||
const fixture = createDependencies();
|
||||
const remoteCount = vi.fn(async () => false);
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(0);
|
||||
fixture.getActiveReplicator.mockReturnValue({ countCompromisedChunks: remoteCount });
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(remoteCount).toHaveBeenCalledOnce();
|
||||
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the fetch choice when compromised chunks are found on the remote", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.getActiveReplicator.mockReturnValue({
|
||||
countCompromisedChunks: vi.fn(async () => 2),
|
||||
});
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ timeout: 0 })
|
||||
);
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(2);
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules the selected recovery and stops start-up", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fetches local-only compromised chunks when FETCH is selected", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps start-up running when compromised chunks are explicitly dismissed", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(countCompromisedChunks).mockResolvedValue(1);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
||||
|
||||
await expect(checkCompromisedChunks(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
expect(fixture.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder";
|
||||
import type { MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
|
||||
|
||||
/** Collaborators required to run one Config Doctor consultation. */
|
||||
export interface ConfigDoctorDependencies {
|
||||
readonly confirm: Confirm;
|
||||
readonly translate: MessageTranslator;
|
||||
readonly settings: ObsidianLiveSyncSettings;
|
||||
readonly setSettings: (settings: ObsidianLiveSyncSettings) => void;
|
||||
readonly saveSettings: () => Promise<void>;
|
||||
readonly rebuilder: Pick<Rebuilder, "scheduleRebuild" | "scheduleFetch">;
|
||||
readonly performRestart: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Config Doctor and, when requested by its result, reserve the next-start
|
||||
* rebuild or fetch operation before restarting the application.
|
||||
*
|
||||
* The positional arguments retain the established defaults and operation
|
||||
* semantics for both start-up and request-event callers.
|
||||
*/
|
||||
export async function runConfigDoctor(
|
||||
dependencies: ConfigDoctorDependencies,
|
||||
skipRebuild: boolean = false,
|
||||
activateReason = "updated",
|
||||
forceRescan = false
|
||||
): Promise<boolean> {
|
||||
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
|
||||
{
|
||||
confirm: dependencies.confirm,
|
||||
translate: dependencies.translate,
|
||||
},
|
||||
dependencies.settings,
|
||||
{
|
||||
localRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
remoteRebuild: skipRebuild ? RebuildOptions.SkipEvenIfRequired : RebuildOptions.AutomaticAcceptable,
|
||||
activateReason,
|
||||
forceRescan,
|
||||
}
|
||||
);
|
||||
if (isModified) {
|
||||
dependencies.setSettings(settings);
|
||||
await dependencies.saveSettings();
|
||||
}
|
||||
if (!skipRebuild) {
|
||||
if (shouldRebuild) {
|
||||
await dependencies.rebuilder.scheduleRebuild();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
} else if (shouldRebuildLocal) {
|
||||
await dependencies.rebuilder.scheduleFetch();
|
||||
dependencies.performRestart();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { performDoctorConsultation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { runConfigDoctor, type ConfigDoctorDependencies } from "./configDoctor";
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/configForDoc", async () => {
|
||||
const actual = await vi.importActual<typeof import("@vrtmrz/livesync-commonlib/compat/common/configForDoc")>(
|
||||
"@vrtmrz/livesync-commonlib/compat/common/configForDoc"
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
performDoctorConsultation: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createDependencies() {
|
||||
const settings = { isConfigured: true } as never;
|
||||
const setSettings = vi.fn();
|
||||
const saveSettings = vi.fn(async () => undefined);
|
||||
const scheduleRebuild = vi.fn(async () => true);
|
||||
const scheduleFetch = vi.fn(async () => true);
|
||||
const performRestart = vi.fn();
|
||||
const dependencies: ConfigDoctorDependencies = {
|
||||
confirm: {} as never,
|
||||
translate: String,
|
||||
settings,
|
||||
setSettings,
|
||||
saveSettings,
|
||||
rebuilder: { scheduleRebuild, scheduleFetch },
|
||||
performRestart,
|
||||
};
|
||||
return { dependencies, performRestart, saveSettings, scheduleFetch, scheduleRebuild, setSettings, settings };
|
||||
}
|
||||
|
||||
describe("runConfigDoctor", () => {
|
||||
it("persists a modified setting and keeps the configured start-up sequence running", async () => {
|
||||
const fixture = createDependencies();
|
||||
const nextSettings = { isConfigured: true, changed: true } as never;
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: nextSettings,
|
||||
shouldRebuild: false,
|
||||
shouldRebuildLocal: false,
|
||||
isModified: true,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(performDoctorConsultation).toHaveBeenCalledWith(
|
||||
{ confirm: fixture.dependencies.confirm, translate: fixture.dependencies.translate },
|
||||
fixture.settings,
|
||||
expect.objectContaining({
|
||||
activateReason: "updated",
|
||||
forceRescan: false,
|
||||
})
|
||||
);
|
||||
expect(fixture.setSettings).toHaveBeenCalledWith(nextSettings);
|
||||
expect(fixture.saveSettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules a rebuild and restarts when Doctor requires remote reconstruction", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: true,
|
||||
shouldRebuildLocal: false,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies, false, "manual", true)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).toHaveBeenCalledOnce();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
expect(performDoctorConsultation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ activateReason: "manual", forceRescan: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("schedules a local fetch and restarts when Doctor requires local reconstruction", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: false,
|
||||
shouldRebuildLocal: true,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies)).resolves.toBe(false);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).toHaveBeenCalledOnce();
|
||||
expect(fixture.performRestart).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips both recovery schedules and restart when rebuilds are skipped", async () => {
|
||||
const fixture = createDependencies();
|
||||
vi.mocked(performDoctorConsultation).mockResolvedValue({
|
||||
settings: fixture.settings,
|
||||
shouldRebuild: true,
|
||||
shouldRebuildLocal: true,
|
||||
isModified: false,
|
||||
});
|
||||
|
||||
await expect(runConfigDoctor(fixture.dependencies, true)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(fixture.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(fixture.performRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+12
-16
@@ -1,34 +1,30 @@
|
||||
export interface ConfiguredStartupLifecycleRuntime {
|
||||
databaseReady: boolean;
|
||||
reportDatabaseNotReady(): void;
|
||||
hasCompromisedChunks(): Promise<boolean>;
|
||||
hasIncompleteDocuments(): Promise<boolean>;
|
||||
waitForCompatibilityReview(): Promise<void>;
|
||||
runDoctor(): Promise<boolean>;
|
||||
migrateBulkSend(): Promise<void>;
|
||||
}
|
||||
import type { ConfiguredStartupLifecycleOperations, StartupLifecycleValue } from "./types";
|
||||
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
configured: boolean;
|
||||
inviteToOnboarding(): void;
|
||||
function readValue<T>(value: StartupLifecycleValue<T>): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an unconfigured Vault outside database initialisation and all
|
||||
* configured-only start-up work while offering an explicit setup action.
|
||||
*/
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
readonly configured: StartupLifecycleValue<boolean>;
|
||||
readonly inviteToOnboarding: () => void;
|
||||
}
|
||||
|
||||
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
|
||||
if (runtime.configured) return true;
|
||||
if (readValue(runtime.configured)) return true;
|
||||
runtime.inviteToOnboarding();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates the inert, unconfigured startup path from checks which must run
|
||||
* Separates the inert, unconfigured start-up path from checks which must run
|
||||
* before an already configured device is allowed to synchronise.
|
||||
*/
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
|
||||
if (!runtime.databaseReady) {
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleOperations): Promise<boolean> {
|
||||
if (!readValue(runtime.databaseReady)) {
|
||||
runtime.reportDatabaseNotReady();
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type StartupEntryLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
import type { ConfiguredStartupLifecycleOperations } from "./types";
|
||||
|
||||
function createRuntime(events: string[] = []): ConfiguredStartupLifecycleOperations {
|
||||
return {
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
waitForCompatibilityReview: vi.fn(async () => {
|
||||
events.push("compatibility-review");
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs all configured checks in their established order", async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(createRuntime(events))).resolves.toBe(true);
|
||||
|
||||
expect(events).toEqual([
|
||||
"compromised-chunks",
|
||||
"incomplete-documents",
|
||||
"compatibility-review",
|
||||
"doctor",
|
||||
"bulk-send",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not invoke later operations after database or integrity failure", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
Object.assign(runtime, { databaseReady: false });
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready"]);
|
||||
|
||||
Object.assign(runtime, { databaseReady: true });
|
||||
vi.mocked(runtime.hasCompromisedChunks).mockImplementation(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return false;
|
||||
});
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
expect(events).toEqual(["database-not-ready", "compromised-chunks"]);
|
||||
expect(runtime.hasIncompleteDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invoke later operations when incomplete-document checking fails", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
expect(runtime.waitForCompatibilityReview).not.toHaveBeenCalled();
|
||||
expect(runtime.runDoctor).not.toHaveBeenCalled();
|
||||
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not migrate bulk-send settings when Config Doctor fails", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = createRuntime(events);
|
||||
vi.mocked(runtime.runDoctor).mockImplementation(async () => {
|
||||
events.push("doctor");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(events).toEqual(["compromised-chunks", "incomplete-documents", "compatibility-review", "doctor"]);
|
||||
expect(runtime.migrateBulkSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls Config Doctor without start-up-only arguments", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await runConfiguredStartupLifecycle(runtime);
|
||||
|
||||
expect(runtime.runDoctor).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("invites an unconfigured Vault and stops configured start-up", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
const runtime: StartupEntryLifecycleRuntime = {
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
};
|
||||
|
||||
expect(runStartupEntryLifecycle(runtime)).toBe(false);
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("admits a configured Vault without inviting it to onboarding", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import {
|
||||
isDeletedEntry,
|
||||
isDocContentSame,
|
||||
isLoadedEntry,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import type { KeyValueDatabase } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { isValidPath } from "@/common/utils";
|
||||
import type { StartupPathReader } from "./types";
|
||||
|
||||
type NoticeGroups = {
|
||||
setItem(groupKey: string, itemKey: string, item: { message: string }): void;
|
||||
finish(groupKey: string): void;
|
||||
};
|
||||
|
||||
/** Focused collaborators for the incomplete-document integrity scan and repair. */
|
||||
export interface IncompleteDocumentsDependencies {
|
||||
readonly localDatabase: Pick<LiveSyncLocalDB, "findAllNormalDocs" | "getDBEntryFromMeta">;
|
||||
readonly getPath: StartupPathReader;
|
||||
readonly isTargetFile: (path: string) => Promise<boolean>;
|
||||
readonly storageAccess: Pick<StorageAccess, "readHiddenFileBinary" | "getFileStub">;
|
||||
readonly fileHandler: Pick<IFileHandler, "storeFileToDB">;
|
||||
readonly keyValueDB: Pick<KeyValueDatabase, "get" | "set">;
|
||||
readonly noticeGroups: NoticeGroups;
|
||||
readonly confirm: Pick<Confirm, "askSelectStringDialogue">;
|
||||
readonly log: LogFunction;
|
||||
}
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
recordedSize: number;
|
||||
actualSize: number;
|
||||
storageSize: number;
|
||||
contentMatched: boolean;
|
||||
isConflicted?: boolean;
|
||||
};
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
/**
|
||||
* Scan database metadata against hidden storage and preserve the former
|
||||
* recoverable-file dialogue and repair rules.
|
||||
*/
|
||||
export async function checkIncompleteDocuments(
|
||||
dependencies: IncompleteDocumentsDependencies,
|
||||
force: boolean = false
|
||||
): Promise<boolean> {
|
||||
const incompleteDocsChecked = (await dependencies.keyValueDB.get<boolean>("checkIncompleteDocs")) || false;
|
||||
if (incompleteDocsChecked && !force) {
|
||||
dependencies.log("Incomplete docs check already done, skipping.", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const noticeGroups = dependencies.noticeGroups;
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
dependencies.log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
|
||||
|
||||
try {
|
||||
const errorFiles = [] as ErrorInfo[];
|
||||
for await (const metaDoc of dependencies.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
const path = dependencies.getPath(metaDoc);
|
||||
|
||||
if (!isValidPath(path)) {
|
||||
continue;
|
||||
}
|
||||
if (!(await dependencies.isTargetFile(path))) {
|
||||
continue;
|
||||
}
|
||||
if (!isMetaEntry(metaDoc)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const doc = await dependencies.localDatabase.getDBEntryFromMeta(metaDoc);
|
||||
if (!doc || !isLoadedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
if (isDeletedEntry(doc)) {
|
||||
continue;
|
||||
}
|
||||
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
|
||||
|
||||
let storageFileContent;
|
||||
try {
|
||||
storageFileContent = await dependencies.storageAccess.readHiddenFileBinary(path);
|
||||
} catch (e) {
|
||||
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
}
|
||||
// const storageFileBlob = createBlob(storageFileContent);
|
||||
const sizeOnStorage = storageFileContent.byteLength;
|
||||
const recordedSize = doc.size;
|
||||
const docBlob = readAsBlob(doc);
|
||||
const actualSize = docBlob.size;
|
||||
if (
|
||||
recordedSize !== actualSize ||
|
||||
sizeOnStorage !== actualSize ||
|
||||
sizeOnStorage !== recordedSize ||
|
||||
isConflicted
|
||||
) {
|
||||
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
|
||||
errorFiles.push({
|
||||
path,
|
||||
recordedSize,
|
||||
actualSize,
|
||||
storageSize: sizeOnStorage,
|
||||
contentMatched,
|
||||
isConflicted,
|
||||
});
|
||||
Logger(
|
||||
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errorFiles.length == 0) {
|
||||
Logger("No size mismatches found", LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: `Found ${errorFiles.length} size mismatches`,
|
||||
});
|
||||
// We have to repair them following rules and situations:
|
||||
// A. DB Recorded != DB Stored
|
||||
// A.1. DB Recorded == Storage Stored
|
||||
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
|
||||
// A.2. Neither
|
||||
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
|
||||
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
|
||||
// B. DB Recorded == DB Stored , < Storage Stored
|
||||
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
|
||||
// We do not fix it automatically, but it will be automatically overwritten in other process.
|
||||
// C. DB Recorded == DB Stored , > Storage Stored
|
||||
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
|
||||
// Also do not fix it automatically. It should be overwritten by replication.
|
||||
const recoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize === e.storageSize && !e.isConflicted;
|
||||
});
|
||||
const unrecoverable = errorFiles.filter((e) => {
|
||||
return e.recordedSize !== e.storageSize || e.isConflicted;
|
||||
});
|
||||
const fileInfo = (e: (typeof errorFiles)[0]) => {
|
||||
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
|
||||
};
|
||||
const messageUnrecoverable =
|
||||
unrecoverable.length > 0
|
||||
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
|
||||
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
})
|
||||
: "";
|
||||
|
||||
const message = $msg("moduleMigration.fix0256.message", {
|
||||
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
|
||||
messageUnrecoverable,
|
||||
});
|
||||
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
|
||||
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
|
||||
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
|
||||
const ret = await dependencies.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
|
||||
title: $msg("moduleMigration.fix0256.title"),
|
||||
defaultAction: CHECK_IT_LATER,
|
||||
});
|
||||
if (ret == FIX) {
|
||||
for (const file of recoverable) {
|
||||
// Overwrite the database with the files on the storage
|
||||
const stubFile = await dependencies.storageAccess.getFileStub(file.path);
|
||||
if (stubFile == null) {
|
||||
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
|
||||
continue;
|
||||
}
|
||||
|
||||
stubFile.stat.mtime = Date.now();
|
||||
const result = await dependencies.fileHandler.storeFileToDB(stubFile, true, false);
|
||||
if (result) {
|
||||
Logger(`Successfully restored ${file.path} from storage`);
|
||||
} else {
|
||||
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
} else if (ret === DISMISS) {
|
||||
// User chose to dismiss the issue
|
||||
await dependencies.keyValueDB.set("checkIncompleteDocs", true);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LoadedEntry, MetaEntry, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { checkIncompleteDocuments, type IncompleteDocumentsDependencies } from "./incompleteDocuments";
|
||||
|
||||
vi.mock("@/common/utils", () => ({
|
||||
isValidPath: () => true,
|
||||
}));
|
||||
|
||||
type DocumentFixture = {
|
||||
meta: MetaEntry;
|
||||
loaded: LoadedEntry;
|
||||
storage: ArrayBuffer;
|
||||
stub: UXFileInfoStub;
|
||||
};
|
||||
|
||||
type FindAllNormalDocs = () => AsyncGenerator<MetaEntry>;
|
||||
|
||||
async function* noDocuments(): AsyncGenerator<MetaEntry> {
|
||||
return;
|
||||
}
|
||||
|
||||
async function* failedDocumentScan(): AsyncGenerator<MetaEntry> {
|
||||
throw new Error("scan failed");
|
||||
}
|
||||
|
||||
function documentFixture(
|
||||
path: string,
|
||||
options: { recordedSize?: number; storageContent?: string; conflicts?: string[] } = {}
|
||||
): DocumentFixture {
|
||||
const storageContent = options.storageContent ?? "hello";
|
||||
const meta = {
|
||||
_id: `f:${path}`,
|
||||
_rev: "1-test",
|
||||
path,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: options.recordedSize ?? storageContent.length,
|
||||
children: ["h:test"],
|
||||
type: "plain",
|
||||
eden: {},
|
||||
...(options.conflicts ? { _conflicts: options.conflicts } : {}),
|
||||
} as MetaEntry;
|
||||
const loaded = {
|
||||
...meta,
|
||||
data: "abc",
|
||||
datatype: "plain",
|
||||
} as LoadedEntry;
|
||||
const stub = {
|
||||
name: path.split("/").pop() ?? path,
|
||||
path,
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: storageContent.length,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfoStub;
|
||||
return {
|
||||
meta,
|
||||
loaded,
|
||||
storage: new TextEncoder().encode(storageContent).buffer as ArrayBuffer,
|
||||
stub,
|
||||
};
|
||||
}
|
||||
|
||||
function documentsFrom(fixtures: DocumentFixture[]): FindAllNormalDocs {
|
||||
return async function* () {
|
||||
yield* fixtures.map((fixture) => fixture.meta);
|
||||
};
|
||||
}
|
||||
|
||||
function selectButton(index: number) {
|
||||
return async (...args: unknown[]): Promise<string | false> => {
|
||||
const buttons = args[1] as readonly string[];
|
||||
return buttons[index] ?? false;
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments, fixtures: DocumentFixture[] = []) {
|
||||
const fixtureByPath = new Map<string, DocumentFixture>(
|
||||
fixtures.map((fixture) => [fixture.meta.path as string, fixture])
|
||||
);
|
||||
const noticeGroups = {
|
||||
setItem: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
};
|
||||
const getFixture = (path: string) => fixtureByPath.get(path);
|
||||
const getDBEntryFromMeta = vi.fn(async (meta: { path: string }) => getFixture(meta.path)?.loaded);
|
||||
const readHiddenFileBinary = vi.fn(async (path: string) => getFixture(path)?.storage ?? new ArrayBuffer(0));
|
||||
const getFileStub = vi.fn(async (path: string) => getFixture(path)?.stub ?? null);
|
||||
const storeFileToDB = vi.fn(async () => true);
|
||||
const askSelectStringDialogue = vi.fn();
|
||||
const dependencies = {
|
||||
localDatabase: {
|
||||
findAllNormalDocs,
|
||||
getDBEntryFromMeta,
|
||||
},
|
||||
getPath: vi.fn((entry: { path: string }) => entry.path),
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
storageAccess: {
|
||||
readHiddenFileBinary,
|
||||
getFileStub,
|
||||
},
|
||||
fileHandler: { storeFileToDB },
|
||||
keyValueDB: {
|
||||
get: vi.fn(async () => false),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
noticeGroups,
|
||||
confirm: { askSelectStringDialogue },
|
||||
log: vi.fn(),
|
||||
} as unknown as IncompleteDocumentsDependencies;
|
||||
return {
|
||||
askSelectStringDialogue,
|
||||
dependencies,
|
||||
getFileStub,
|
||||
noticeGroups,
|
||||
readHiddenFileBinary,
|
||||
storeFileToDB,
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkIncompleteDocuments", () => {
|
||||
it("keeps the check and result in one persistent named group", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies();
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
||||
message: "Checking for incomplete documents...",
|
||||
});
|
||||
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
||||
message: "No size mismatches found",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
expect(dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("skips the non-forced check after a successful prior scan", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies();
|
||||
vi.mocked(dependencies.keyValueDB.get).mockResolvedValue(true);
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(noticeGroups.setItem).not.toHaveBeenCalled();
|
||||
expect(noticeGroups.finish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finishes the group with a failure result when the scan throws", async () => {
|
||||
const { dependencies, noticeGroups } = createDependencies(failedDocumentScan);
|
||||
|
||||
await expect(checkIncompleteDocuments(dependencies)).rejects.toThrow("scan failed");
|
||||
|
||||
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
||||
message: "The incomplete document check could not be completed.",
|
||||
});
|
||||
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
||||
});
|
||||
|
||||
it("repairs a recoverable document when FIX is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
||||
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves recoverable documents unchanged when CHECK_IT_LATER is selected", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a permanent dismissal of recoverable document warnings", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
||||
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
||||
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
||||
});
|
||||
|
||||
it("stores only recoverable, non-conflicted documents from a mixed scan", async () => {
|
||||
const recoverable = documentFixture("recoverable.md");
|
||||
const unrecoverable = documentFixture("unrecoverable.md", { recordedSize: 4 });
|
||||
const conflicted = documentFixture("conflicted.md", { conflicts: ["2-conflict"] });
|
||||
const fixture = createDependencies(documentsFrom([recoverable, unrecoverable, conflicted]), [
|
||||
recoverable,
|
||||
unrecoverable,
|
||||
conflicted,
|
||||
]);
|
||||
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
||||
|
||||
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.getFileStub).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("unrecoverable.md"));
|
||||
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("conflicted.md"));
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user