mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-04 01:37:05 +00:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f055222160 | ||
|
|
77882c677b | ||
|
|
2461d37ead | ||
|
|
d00c5ecc56 | ||
|
|
7b4f7bf514 | ||
|
|
ba8f910865 | ||
|
|
c58e462057 | ||
|
|
a09c59aaba | ||
|
|
cbbae33d67 | ||
|
|
be9c328f4a | ||
|
|
a9b146a0ab | ||
|
|
5d5e448c6b | ||
|
|
725db213c3 | ||
|
|
a2441b9870 | ||
|
|
01c38268c7 | ||
|
|
64e17ab920 | ||
|
|
d8fccd6e4b | ||
|
|
50ad4c4bdf | ||
|
|
8cb5d6d87f | ||
|
|
79eb940d80 | ||
|
|
28e76701c4 | ||
|
|
c8aa40775b | ||
|
|
fdf5de4c10 | ||
|
|
9e9fb5c326 | ||
|
|
2b450a300a | ||
|
|
ef3033d8db | ||
|
|
8e506fdbc7 | ||
|
|
cab6679c2b | ||
|
|
f737701695 | ||
|
|
565749ef6c | ||
|
|
32c7cc8aca | ||
|
|
5c5f454286 | ||
|
|
c4d9f8687e | ||
|
|
bd0581cdc9 | ||
|
|
9592ce8529 | ||
|
|
e2d1bb09ae | ||
|
|
3f76ad796e | ||
|
|
1d2077d3fc | ||
|
|
24228bf7cf | ||
|
|
9a28d46287 | ||
|
|
3a23b7a6b0 | ||
|
|
23dd4ab87e | ||
|
|
6364988453 | ||
|
|
76318944a4 | ||
|
|
704e141fd9 | ||
|
|
69cd6250d2 | ||
|
|
72f033fca4 | ||
|
|
415f81d533 | ||
|
|
91625a1c76 | ||
|
|
a5756503a2 | ||
|
|
7300db08d6 | ||
|
|
4b47ebbd4d | ||
|
|
b28871ab67 | ||
|
|
db70b4c2b6 | ||
|
|
f7206b1a6e | ||
|
|
fc160ee060 | ||
|
|
22b0cc133a | ||
|
|
948f961caa | ||
|
|
ad83ae858d | ||
|
|
cc000f2cdf | ||
|
|
d24bda88be | ||
|
|
4dcc783a71 | ||
|
|
be03c25904 | ||
|
|
7cf4ec49ed | ||
|
|
8e5b058eef | ||
|
|
56444bb98b | ||
|
|
7aa41baf08 | ||
|
|
2c35f45765 | ||
|
|
caa8c92cbe | ||
|
|
011a8405b5 | ||
|
|
f5f7aab11f | ||
|
|
9854319c96 | ||
|
|
4ab0689c7f | ||
|
|
edeac6f7e2 | ||
|
|
783fbb8f23 | ||
|
|
8644af6128 | ||
|
|
4ff5b4dfe8 | ||
|
|
0b1f5ca719 | ||
|
|
9de4f2952d | ||
|
|
330f2acd42 | ||
|
|
1a84b08de5 | ||
|
|
ce7988fe7c | ||
|
|
537e0e42c3 | ||
|
|
a5e1acb960 | ||
|
|
b8191e548d | ||
|
|
1c3feb526c | ||
|
|
aebf4874b3 | ||
|
|
3ea8eac212 | ||
|
|
f6eefed97c | ||
|
|
131ffeb0ef | ||
|
|
070ce0e307 | ||
|
|
8adc88b8cb | ||
|
|
dd11a753d5 | ||
|
|
47759f6205 | ||
|
|
d8f7762d01 | ||
|
|
ebaf89f822 | ||
|
|
06b9f32bdf | ||
|
|
32e827692f | ||
|
|
cdf6935042 | ||
|
|
665a5b5b61 | ||
|
|
83faec9280 | ||
|
|
d0bdae2676 | ||
|
|
cfbf94a38a | ||
|
|
2cefff43bb | ||
|
|
6b37ea8778 | ||
|
|
35e170463e | ||
|
|
694371898a | ||
|
|
3f1d4efd86 | ||
|
|
d27a5b3725 | ||
|
|
cbd42bdecb | ||
|
|
e20386b8e2 | ||
|
|
f09b4c779e | ||
|
|
552d286392 | ||
|
|
22ae1b4a6e | ||
|
|
d1ae42a134 | ||
|
|
c7443ee728 | ||
|
|
f8ee3c8662 | ||
|
|
fbe868092a | ||
|
|
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,34 +129,42 @@ Changes spanning both repositories must first produce a packed Commonlib artefac
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module System
|
||||
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.
|
||||
|
||||
The plugin uses a dynamic module system to reduce coupling and improve maintainability:
|
||||
### Service composition and legacy Modules
|
||||
|
||||
- **Service Hub**: Central registry for services using dependency injection
|
||||
- Services are registered, and accessed via `this.services` (in most modules)
|
||||
- **Module Loading**: All modules extend `AbstractModule` or `AbstractObsidianModule` (which extends `AbstractModule`). These modules are loaded in main.ts and some modules.
|
||||
- **Module Categories** (by directory):
|
||||
- `core/` - Platform-independent core functionality
|
||||
- `coreObsidian/` - Obsidian-specific core (e.g., `ModuleFileAccessObsidian`)
|
||||
- `essential/` - Required modules (e.g., `ModuleMigration`, `ModuleKeyValueDB`)
|
||||
- `features/` - Optional features (e.g., `ModuleLog`, `ModuleObsidianSettings`)
|
||||
- `extras/` - Development/testing tools (e.g., `ModuleDev`, ~~`ModuleIntegratedTest`~~)
|
||||
- **Services**: Core services (e.g., `database`, `replicator`, `storageAccess`) are registered in `ServiceHub` and accessed by modules. They provide an extension point for add new behaviour without modifying existing code.
|
||||
- For example, checks before the replication can be added to the `replication.onBeforeReplicate` handler, and the handlers can be return `false` to prevent replication-starting. `vault.isTargetFile` also can be used to prevent processing specific files.
|
||||
- **ServiceModule**: A new type of module that directly depends on services.
|
||||
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
|
||||
|
||||
#### Note on Module vs Service
|
||||
- **Service Hub**: the long-lived registry of service contracts. Add a simple extension, such as a pre-replication check, to the handler owned by the relevant Service.
|
||||
- **ServiceModule**: a host-created, long-lived stateful or resource-owning capability shared through the typed `ServiceModules` record. Current examples include storage access, file handling, and database rebuilding.
|
||||
- **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.
|
||||
|
||||
After v0.25.44 refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. - They will be implemented directly in the service. - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted.
|
||||
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.
|
||||
|
||||
Hence, the new feature should be implemented as follows:
|
||||
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`.
|
||||
|
||||
- If it is a simple extension point (e.g., adding a check before replication), it should be implemented as a handler in the service (e.g., `replication.onBeforeReplicate`).
|
||||
- If it requires maintaining state or making decisions based on multiple handlers, it should be implemented as a serviceModule dependent on the relevant services explicitly.
|
||||
- If you have to implement a new feature without much modification, you can extent existing modules, but it is recommended to implement a new module or serviceModule for better maintainability.
|
||||
- Refactoring existing modules to services is also always welcome!
|
||||
- Please write tests for new features, you will notice that the simple handler approach is quite testable.
|
||||
Use a class when stable object identity, replaceable implementations, or an explicit external-resource lifecycle such as serialised ownership, `dispose()`, or `abort()` is part of the contract. Use a ServiceModule when that operational capability or resource lifecycle must also be shared explicitly by several consumers. Do not introduce a class merely to group dependencies or make private functions callable.
|
||||
|
||||
Several narrow views over one lifetime do not require several state owners or a public façade class. One private context may back all of those views, provided that the context remains private and each consumer receives only its declared contract. Keep actual resource owners separate when identity, serialised replacement, abort, retirement, or disposal order is part of their behaviour.
|
||||
|
||||
When a core-owned serviceFeature returns a view needed by one host-specific consumer, pass that view through host composition instead of storing it as a public `LiveSyncBaseCore` property or promoting it to a ServiceModule. The receiving host should inject the view into the narrow command or application context which uses it.
|
||||
|
||||
Commonlib's `targetFilter.ts` and `prepareDatabaseForUse.ts` demonstrate the intended split: focused factories or operations own their private state and behaviour, while the corresponding `use...` function composes dependencies and registers handlers. The P2P composition follows the same direction at a larger scale by separating durable policy and room-session ownership from host lifecycle and user-interface wiring. Existing Modules do not apply this boundary consistently; improve the affected boundary when changing their behaviour rather than performing an unrelated mechanical conversion.
|
||||
|
||||
Use interaction-based, London School unit tests for the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration, then test the focused state owner for its transitions and invariants. If a test needs a broad core fixture, a large class mock, deep mock chains, or unrelated Services, treat that friction as a design-review signal and consider a private context with narrower functions before adding more test machinery.
|
||||
|
||||
See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary.
|
||||
|
||||
Legacy Modules remain grouped by directory:
|
||||
|
||||
- `core/` contains platform-independent core behaviour;
|
||||
- `coreObsidian/` contains Obsidian-specific core behaviour;
|
||||
- `essential/` contains required Modules;
|
||||
- `features/` contains optional features; and
|
||||
- `extras/` contains development and testing tools.
|
||||
|
||||
### Key Architectural Components
|
||||
|
||||
@@ -165,7 +173,12 @@ Hence, the new feature should be implemented as follows:
|
||||
- **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 the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root.
|
||||
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
|
||||
|
||||
@@ -227,30 +240,37 @@ Commonlib owns the typed English fallback for messages requested by its services
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Module Implementation (Now not recommended for new features, use services instead)
|
||||
### Service feature implementation
|
||||
|
||||
```typescript
|
||||
export class ModuleExample extends AbstractObsidianModule {
|
||||
async _everyOnloadStart(): Promise<boolean> {
|
||||
/* ... */
|
||||
}
|
||||
type ExampleHost = NecessaryServices<"appLifecycle" | "API", never>;
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.handleOnInitialise(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
export const useExampleFeature = createServiceFeature((host: ExampleHost) => {
|
||||
host.services.appLifecycle.onLoaded.addHandler(async () => {
|
||||
host.services.API.addLog("Example feature loaded");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Existing legacy Modules continue to register their handlers in `onBindFunction()`. Follow [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) when new behaviour touches one of those Modules.
|
||||
|
||||
### Settings Management
|
||||
|
||||
- Settings are defined by Commonlib (`ObsidianLiveSyncSettings`)
|
||||
- Configuration metadata is supplied by the Commonlib settings exports
|
||||
- Obsidian may request declarative definitions immediately from
|
||||
`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
|
||||
[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
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
A fully self-hosted CouchDB stack for the [obsidian-livesync](https://github.com/vrtmrz/obsidian-livesync) plugin.
|
||||
**No fly.io. No IBM Cloudant. No cloud accounts required for basic use.**
|
||||
|
||||
The optional [Coturn Compose starter](coturn/README.md) is a separate Linux-only service for P2P connectivity. It is not part of the CouchDB stack below.
|
||||
|
||||
> ✅ **Tested on Docker Desktop for Windows (Docker 29.2, Compose v5, WSL2 backend)** — full init, CORS, auth, and idempotent restart verified.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Public DNS name used as the TURN authentication realm.
|
||||
TURN_REALM=
|
||||
|
||||
# Public IPv4 address advertised by Coturn. If Coturn is behind NAT, forward
|
||||
# port 3478 (TCP and UDP) and the UDP relay range to this host.
|
||||
TURN_EXTERNAL_IP=
|
||||
|
||||
# Static long-term credential used by the LiveSync P2P profile. Use a simple
|
||||
# username without a colon and a high-entropy password, such as hexadecimal.
|
||||
TURN_USERNAME=
|
||||
TURN_PASSWORD=
|
||||
@@ -0,0 +1 @@
|
||||
/.env
|
||||
@@ -0,0 +1,81 @@
|
||||
# Coturn starter for LiveSync P2P
|
||||
|
||||
This optional Compose project runs a small, static-credential TURN service for LiveSync P2P. It uses the upstream `coturn/coturn` image directly; the repository does not maintain a separate Coturn Dockerfile.
|
||||
|
||||
The starter is deliberately limited to a Linux server with a public IPv4 address, TURN over UDP and TCP on port 3478, and UDP relay ports 49160–49200. It does not configure TLS, automatic certificate renewal, monitoring, quotas, or a managed credential endpoint.
|
||||
|
||||
## Before starting
|
||||
|
||||
Prepare:
|
||||
|
||||
- a Linux host with Docker Engine and the Compose plug-in;
|
||||
- a public IPv4 address, either on the host or forwarded to it;
|
||||
- a DNS name such as `turn.example.com`;
|
||||
- firewall and NAT rules for TCP and UDP port 3478, and UDP ports 49160–49200; and
|
||||
- enough bandwidth for every relayed P2P transfer.
|
||||
|
||||
Docker host networking is intentional. Coturn's upstream image recommends it because forwarding a large relay port range through Docker performs poorly. This starter therefore does not support Docker Desktop.
|
||||
|
||||
## Configure and start
|
||||
|
||||
From this directory:
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
Set every value in `.env`. Generate a high-entropy password, for example:
|
||||
|
||||
```sh
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Use a simple username without a colon. The static username and password are passed to Coturn as process arguments. They are visible to a local Docker administrator, who already controls the host. The `.env` file is excluded from Git and should remain private. The resolved output of `docker compose config` also contains the credential, so do not publish it.
|
||||
|
||||
Validate the resolved configuration, then start it:
|
||||
|
||||
```sh
|
||||
docker compose config
|
||||
docker compose up -d
|
||||
docker compose logs -f coturn
|
||||
```
|
||||
|
||||
The pinned image version is deliberate. Review the upstream Coturn release notes and update the pin explicitly rather than following `latest` automatically.
|
||||
|
||||
## Configure LiveSync
|
||||
|
||||
Enter both client paths in the P2P profile's TURN server list:
|
||||
|
||||
```text
|
||||
turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp
|
||||
```
|
||||
|
||||
Use `TURN_USERNAME` and `TURN_PASSWORD` as the TURN username and credential. Keep the normal `Automatic` ICE policy unless a future LiveSync release offers `TURN relay only` and the direct path needs to be excluded deliberately.
|
||||
|
||||
Both synchronising devices must be able to reach the server. Prove an explicit two-way `Replicate now` round trip on the intended networks before relying on the configuration.
|
||||
|
||||
The repository check can validate Compose expansion and local TURN allocations. It cannot prove the public firewall, NAT, carrier, or client path for a particular deployment. Validate both UDP and TCP from outside the server network.
|
||||
|
||||
## TLS and port 443 are advanced extensions
|
||||
|
||||
This starter does not recommend putting Coturn on port 443. Coturn cannot bind to the same IP address and TCP port as Caddy or another HTTPS entry point. In particular, it conflicts with the bundled CouchDB Caddy profile when both use the same host address.
|
||||
|
||||
If a restrictive network requires TURN over TLS on port 443, prefer a separate TURN host or a separate public IP address. An outbound tunnel used for CouchDB may also leave the host's public port 443 available for Coturn, provided that TURN uses a separate DNS record which resolves directly to that host. The tunnel itself does not carry TURN traffic.
|
||||
|
||||
A single public IP can technically be shared when one layer-4 TLS router owns port 443 and routes separate CouchDB and TURN hostnames by Server Name Indication (SNI). This adds another certificate and connection-routing boundary, depends on every intended TURN client supplying usable SNI, and is outside this starter. The standard Caddy image used by the bundled CouchDB profile does not provide that layer-4 routing.
|
||||
|
||||
TURN over TLS is not HTTP. An ordinary HTTP reverse proxy or Cloudflare Tunnel route is not a substitute for a TURN listener. Follow Coturn's upstream configuration guidance for `tls-listening-port`, `cert`, and `pkey`, arrange renewal and restart behaviour, and test the resulting `turns:` URL from outside the server network.
|
||||
|
||||
This starter disables Coturn's TLS listener and does not add a TURN-over-DTLS path, so it cannot appear to provide a secure TURN port without those operator-owned prerequisites. This does not disable the end-to-end DTLS encryption used by the WebRTC peer connection carried through TURN.
|
||||
|
||||
## Security and operations
|
||||
|
||||
- Rotate the static credential if the Setup URI, `.env` file, or credential is exposed.
|
||||
- Treat TURN as an internet-facing bandwidth service and monitor traffic and logs.
|
||||
- Add appropriate allocation and bandwidth quotas for a shared or public deployment.
|
||||
- Keep the private-address restrictions unless the TURN server is intentionally permitted to relay to those networks.
|
||||
- Keep independent Vault backups. TURN improves connection reachability; it does not store a backup of Vault data.
|
||||
- A TURN operator can observe endpoint addresses, timing, and traffic volume even though LiveSync content remains end-to-end encrypted.
|
||||
|
||||
The authoritative image and configuration references are the [Coturn Docker image guide](https://github.com/coturn/coturn/blob/master/docker/coturn/README.md) and [Coturn server documentation](https://github.com/coturn/coturn/blob/master/README.turnserver).
|
||||
@@ -0,0 +1,33 @@
|
||||
name: livesync-coturn
|
||||
|
||||
services:
|
||||
coturn:
|
||||
image: coturn/coturn:4.17.2
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# Compose has already interpolated the environment values. Invoke Coturn
|
||||
# directly so that the image's shell entrypoint does not expand them again.
|
||||
entrypoint:
|
||||
- turnserver
|
||||
command:
|
||||
- "-n"
|
||||
- "--log-file=stdout"
|
||||
- "--pidfile=/tmp/turnserver.pid"
|
||||
- "--listening-ip=0.0.0.0"
|
||||
- "--listening-port=3478"
|
||||
- "--min-port=49160"
|
||||
- "--max-port=49200"
|
||||
- "--external-ip=${TURN_EXTERNAL_IP:?Set TURN_EXTERNAL_IP in docker/coturn/.env}"
|
||||
- "--realm=${TURN_REALM:?Set TURN_REALM in docker/coturn/.env}"
|
||||
- "--user=${TURN_USERNAME:?Set TURN_USERNAME in docker/coturn/.env}:${TURN_PASSWORD:?Set TURN_PASSWORD in docker/coturn/.env}"
|
||||
- "--fingerprint"
|
||||
- "--lt-cred-mech"
|
||||
- "--stale-nonce=600"
|
||||
- "--unauthorized-ratelimit"
|
||||
- "--no-multicast-peers"
|
||||
- "--denied-peer-ip=10.0.0.0-10.255.255.255"
|
||||
- "--denied-peer-ip=100.64.0.0-100.127.255.255"
|
||||
- "--denied-peer-ip=169.254.0.0-169.254.255.255"
|
||||
- "--denied-peer-ip=172.16.0.0-172.31.255.255"
|
||||
- "--denied-peer-ip=192.168.0.0-192.168.255.255"
|
||||
- "--no-tls"
|
||||
@@ -267,7 +267,7 @@ The Community directory scanner preview completes with no source-code errors. Th
|
||||
|
||||
The remaining source warnings belong to application code. Browser dialogue visibility now uses DOM state instead of inline static styling, so the earlier styling warning is absent. Direct diagnostic output was resolved at its existing ownership boundaries: Webapp components use an injected log function backed by `BrowserAPIService`, WebPeer retains output in its Svelte log store, Obsidian modules use the established Logger path, and duplicate console emission was removed from `ModuleLog`. The later Webapp and WebPeer recomposition around maintained Context and serviceFeature APIs should preserve these explicit output paths.
|
||||
|
||||
The final Community lint inventory for this boundary has no errors and 126 warnings: 67 sentence-case findings, 58 deprecated-API findings, and one declarative setting-definition suggestion. The sentence-case strings and deprecated interfaces are retained deliberately to avoid an unrelated localisation and host-lifecycle change. Declarative definitions would migrate the complete Obsidian setting tab into the 1.13 settings-search model; that is a separate visible UI project after LiveSync 1.0, not a hidden package-boundary release gate. Revisit each category through focused UI and compatibility work rather than suppressing the rules or treating the warning count as zero.
|
||||
The final Community lint inventory for this boundary has no errors and 126 warnings: 67 sentence-case findings, 58 deprecated-API findings, and one declarative setting-definition suggestion. The sentence-case strings and deprecated interfaces are retained deliberately to avoid an unrelated localisation and host-lifecycle change. Declarative definitions would migrate the complete Obsidian setting tab into the 1.13 settings-search model; that is a separate visible UI project after LiveSync 1.0, not a hidden package-boundary release gate. Revisit each category through focused UI and compatibility work rather than suppressing the rules or treating the warning count as zero. The declarative-settings suggestion was subsequently addressed by [Adapt Standard Settings to Obsidian's Declarative API](2026_08_declarative_settings_adapter.md); the figures above remain the historical package-boundary inventory.
|
||||
|
||||
WebPeer's production build still reports that Vite externalises the guarded Node `crypto` fallback reached through a compatibility path. Browser execution selects `globalThis.crypto`, and the focused root, `context`, and `browser` bundle checks do not include the Node fallback, so this is not a leak in the reviewed public browser entries. Removing the compatibility-build warning requires a focused crypto-capability contract or a platform-specific implementation split and remains part of compatibility-surface narrowing.
|
||||
|
||||
@@ -275,7 +275,7 @@ The dependency preview also reports `uuid`, but the installed and locked graph r
|
||||
|
||||
The 1.0 dependency review found newly disclosed parser and denial-of-service advisories with compatible fixes. All locked `brace-expansion` generations now use their patched releases, including the production generation reached by Commonlib path matching and the CLI's user-configured ignore patterns. The development-only ESLint and Istanbul `js-yaml` generations likewise use patched releases. The production Markdown parser uses the patched `linkify-it` release to avoid quadratic processing of maliciously structured `mailto:` links, while the development-only JSON Schema toolchain uses the patched `fast-uri` release for unambiguous hostname parsing. A clean install and both complete and production-only `npm audit` checks no longer report these packages.
|
||||
|
||||
The remaining audit report is the existing `werift` and `werift-ice` dependency on `ip`, for which npm offers no patched version. The advisory concerns `ip.isPublic()` misclassifying unusual loopback representations. The locked werift implementation uses `ip` for address encoding, decoding, format detection, and loopback filtering, but does not call `isPublic()` or `isPrivate()`. LiveSync reaches werift only through the Node CLI's injected `RTCPeerConnection`; the Obsidian plug-in and browser applications use their platform WebRTC implementation, and the plug-in artefact does not contain werift. The package-level finding is therefore accepted for the 1.0 integration preview as a non-reachable advisory in the reviewed call path, not as a general waiver. Revisit it when werift or `ip` publishes a replacement, or before any change which delegates address trust, routing, or URL access decisions to that dependency.
|
||||
The initial 1.0 dependency review also found `werift` and `werift-ice` pulling in `ip`, for which npm offers no patched version. The advisory concerns `ip.isPublic()` misclassifying unusual loopback representations. LiveSync reached werift only through the Node CLI's injected `RTCPeerConnection`, and the reviewed call path did not use `isPublic()` or `isPrivate()`. This temporary acceptance ended when werift removed the dependency: the CLI now uses `werift` 0.24.4 or later, the installed production graph contains no `ip` package, and `npm audit --omit=dev` reports no vulnerabilities.
|
||||
|
||||
The local real-Obsidian suite verifies the actual loaded `ObsidianServiceContext`, all 18 services, Vault reflection, CouchDB and Object Storage transfer, remote-activity accounting, CLI-to-Obsidian encrypted synchronisation, startup scanning, two-Vault create, update, delete, ordinary rename, case-only rename, target mismatch, Hidden File Sync, Customisation Sync, setting Markdown export, and two-device CouchDB, Object Storage, and P2P Setup URI workflows. These checks establish observable results and host composition rather than relying on declaration compatibility alone.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Version 1.0 needs to distinguish supported opt-in features from previews and fro
|
||||
- `xxhash64` is the current hash contract. Other hash algorithms remain available for existing databases and edge-case recovery, not as experimental alternatives for new Vaults.
|
||||
- Eden chunks remain accepted at runtime and in transported settings, but are not offered in the settings interface.
|
||||
- `doNotUseFixedRevisionForChunks` remains an inert compatibility input. Chunk revisions are always content-derived.
|
||||
- The deprecated cleaned-database reconciliation callback remains internal while an old IndexedDB client may still encounter that remote state. It is not a user-selectable maintenance action and is omitted from the settings reference.
|
||||
- The cleaned-database compatibility path remains internal while an old IndexedDB client may still encounter that remote state. It is not a user-selectable maintenance action and is omitted from the settings reference.
|
||||
|
||||
### Already removed
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
---
|
||||
date: 2026-08-25
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.20"
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Adapt Standard Settings to Obsidian's Declarative API
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented through Stage C1 and two bounded Stage C2
|
||||
landing-page improvements. The shared specification remains deliberately
|
||||
limited to one-key, immediately persisted controls. Complex pages retain their
|
||||
existing renderers instead of being forced through a general abstraction.
|
||||
Settings pending application which require database initialisation now delegate
|
||||
their decision, scheduling, and restart boundary to `SetupManager` and
|
||||
`Rebuilder`. Setting-tab registration and definition construction also follow
|
||||
the persisted-settings lifecycle rather than transient runtime readiness.
|
||||
|
||||
## Context
|
||||
|
||||
Obsidian 1.13 introduced declarative plug-in settings through
|
||||
`PluginSettingTab.getSettingDefinitions()`. Declarative definitions are used for
|
||||
native rendering, validation, navigation, and global settings search. When the
|
||||
method returns a non-empty array, Obsidian does not call the existing
|
||||
`display()` implementation.
|
||||
|
||||
Obsidian may call `getSettingDefinitions()` as soon as a tab is passed to
|
||||
`Plugin.addSettingTab()`. Registering the tab during initialisation therefore
|
||||
allowed definition construction to observe constructor defaults before
|
||||
persisted settings had loaded. The former landing-page predicate also inspected
|
||||
the active replicator, although the local database and replicator are created
|
||||
only after the settings-loaded lifecycle. On start-up this ordering could emit
|
||||
a spurious missing-replicator warning and produce a landing-page order from
|
||||
transient state.
|
||||
|
||||
Self-hosted LiveSync still supports Obsidian versions before 1.13 through its
|
||||
`minAppVersion` of 1.7.2. It must therefore retain an imperative `display()`
|
||||
fallback unless the minimum supported Obsidian version is raised separately.
|
||||
Maintaining an unrelated declarative definition and imperative implementation
|
||||
for each setting would allow the two interfaces to drift.
|
||||
|
||||
The current `LiveSyncSetting` AutoWire implementation combines several
|
||||
responsibilities:
|
||||
|
||||
- Commonlib setting metadata supplies setting names, descriptions, maturity, and
|
||||
configuration level;
|
||||
- pane functions decide page and group membership, control type, options, and
|
||||
conditional visibility;
|
||||
- `LiveSyncSetting` creates and updates Obsidian DOM components;
|
||||
- `ObsidianLiveSyncSettingTab` owns an editing buffer, dirty state, local and
|
||||
persisted settings, and save operations; and
|
||||
- selected controls add staged Apply behaviour, derived values, or effects
|
||||
which run after a successful save.
|
||||
|
||||
These responsibilities are not all declarative setting data. In particular,
|
||||
Remote Configuration, Hatch, Maintenance, Help, and Selector contain dynamic
|
||||
lists, Svelte components, diagnostic results, multi-step actions, and
|
||||
destructive confirmations. Encoding those interactions in a general settings
|
||||
DSL would increase the abstraction before a second renderer had proved which
|
||||
parts are genuinely shared.
|
||||
|
||||
Commonlib's `SettingInformation` setting metadata contains more entries than
|
||||
the current settings interface exposes. Generating definitions from all of it
|
||||
would therefore make compatibility or internal settings searchable merely
|
||||
because they have labels. Page membership must remain an explicit LiveSync
|
||||
decision.
|
||||
|
||||
The existing legacy settings wizard also changes DOM classes and selects panes
|
||||
through `enableMinimalSetup()`. The maintained onboarding path now uses
|
||||
`SetupManager`. The current call graph has no caller for
|
||||
`askAgainForSetupURI()`: it is the only emitter of
|
||||
`EVENT_REQUEST_OPEN_SETTING_WIZARD`, and that event's only handler calls
|
||||
`enableMinimalSetup()`. The old route is therefore obsolete rather than a
|
||||
second onboarding interface which the declarative renderer must preserve.
|
||||
Historically, it was the second prompt after a user reported having no Setup
|
||||
URI. It offered the in-settings wizard, P2P setup, manual settings, or a reminder
|
||||
at the next launch, then stopped initialisation while the selected interface
|
||||
took over. `SetupManager` now owns that decision and continuation.
|
||||
|
||||
## Decision
|
||||
|
||||
### Retire the obsolete in-settings wizard first
|
||||
|
||||
The old in-settings wizard will be removed as a focused prerequisite. This is
|
||||
cleanup of an unreachable interface, not part of the declarative settings
|
||||
model. The cleanup will remove:
|
||||
|
||||
- `askAgainForSetupURI()`, `EVENT_REQUEST_OPEN_SETTING_WIZARD`, its handler, and
|
||||
`enableMinimalSetup()`;
|
||||
- the `inWizard` completion branch in Sync Settings;
|
||||
- the `isWizard`, `wizardHidden`, and `wizardOnly` styling contract;
|
||||
- the General-page `Next` control and the already commented Remote
|
||||
Configuration `Next` control;
|
||||
- the now-unnecessary `wizardHidden` argument on the old pane builder; and
|
||||
- message keys whose final production consumer is the removed route, followed
|
||||
by the normal catalogue regeneration.
|
||||
|
||||
This cleanup does not affect `SetupManager`, Setup URI onboarding, QR-code
|
||||
navigation, document-history navigation, or any other control which happens to
|
||||
use the word 'Next'. The existing onboarding and ordinary settings E2E paths
|
||||
must pass before the declarative work begins.
|
||||
|
||||
The English quick-setup documentation already describes the maintained
|
||||
onboarding. Older localised quick-setup pages which still describe the removed
|
||||
interface are documentation maintenance rather than a prerequisite for this
|
||||
runtime cleanup.
|
||||
|
||||
### Use one explicit page catalogue
|
||||
|
||||
LiveSync will define one ordered page catalogue. It will be the sole source for
|
||||
page identity, name, configuration level, visibility, and content ownership.
|
||||
Each entry keeps the existing pane renderer for the legacy path and selects one
|
||||
of two native content forms:
|
||||
|
||||
```typescript
|
||||
type SettingsPageEntry = {
|
||||
id: string;
|
||||
name: () => string;
|
||||
icon: string;
|
||||
order: number;
|
||||
level?: ConfigLevel;
|
||||
content: "native" | "custom";
|
||||
legacy: PaneRenderer;
|
||||
};
|
||||
```
|
||||
|
||||
In Stage C1, `native` identifies the Advanced proof page, whose definitions are
|
||||
supplied by the adapter, and `custom` selects the shared lazy custom-page
|
||||
factory. The catalogue will gain a per-page native factory only when a second
|
||||
native page requires one; Stage C1 does not introduce that abstraction in
|
||||
advance.
|
||||
|
||||
A native `items` page may mix groups of `SettingSpec` controls with Obsidian's
|
||||
direct action, render, list, and nested-page definitions. A native custom
|
||||
`SettingPage` is the final escape hatch when the page cannot yet be divided
|
||||
safely. The imperative and declarative interfaces consume the same catalogue:
|
||||
|
||||
| Catalogue content | Obsidian before 1.13 | Obsidian 1.13 and later |
|
||||
| ------------------------------------- | -------------------------------- | ------------------------------------------ |
|
||||
| Standard `SettingSpec` | Render through `LiveSyncSetting` | Convert to a control definition |
|
||||
| Native group, action, or rendered row | Use the existing pane renderer | Use `SettingDefinitionPage.items` |
|
||||
| Full custom page | Use the existing pane renderer | Open a lazily created custom `SettingPage` |
|
||||
|
||||
This makes page names and visibility consistent without requiring every page
|
||||
to migrate at once. Page names must be unique because Obsidian uses them for
|
||||
nested navigation.
|
||||
|
||||
`SettingDefinitionPage` does not expose a separate icon field. The declarative
|
||||
renderer therefore prefixes each native page name with the emoji already held
|
||||
by the catalogue, while the imperative renderer continues to pass the same
|
||||
emoji to its existing menu button. This preserves the established visual
|
||||
identity without adding host-DOM manipulation.
|
||||
|
||||
`SettingDefinitionGroup` likewise exposes only a string heading. Root groups
|
||||
therefore have semantic identifiers whose catalogue entries keep their emoji
|
||||
and late-translated names separate. The adapter combines those fields only
|
||||
when constructing the Obsidian definition, so callers select a group by its
|
||||
identifier instead of repeating presentation strings.
|
||||
|
||||
### Compose the native landing page around common tasks
|
||||
|
||||
The declarative root is a composition of native groups and catalogue pages,
|
||||
not a second flat copy of the legacy tab menu. General Settings contains the
|
||||
native Appearance, Logging, and Extra menus child pages. Their standard
|
||||
`SettingSpec` controls remain searchable without crowding the root. The small
|
||||
Quick Setup actions are native action rows on the root. The old Setup child
|
||||
page is not retained: its feature-level controls move to Extra menus, its full
|
||||
reset moves to Maintenance, and its online guidance becomes Help and
|
||||
troubleshooting. The pane-based interface exposes Quick Setup as a pane and
|
||||
renders the same controls within General Settings.
|
||||
|
||||
Remote Configuration and Sync Settings remain catalogue pages. Obsidian's
|
||||
native group contract permits navigable pages as group items, so both pages are
|
||||
placed inside an explicit Synchronisation group. This keeps them near the top
|
||||
for narrow mobile displays while preventing the unheaded page entries from
|
||||
appearing to continue the preceding Quick Setup group. The root order reflects
|
||||
the current task:
|
||||
|
||||
| Configuration state | First root sections |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Unconfigured | Quick Setup, Synchronisation (Remote Configuration and Sync Settings), then General |
|
||||
| Configured | Synchronisation (Remote Configuration and Sync Settings), General, Set up other devices, then Quick Setup |
|
||||
|
||||
Set up other devices is hidden until the plug-in is configured. The remaining
|
||||
destinations are grouped explicitly:
|
||||
|
||||
| Group | Pages |
|
||||
| ------------------------ | ---------------------------------------- |
|
||||
| Maintenance and recovery | Maintenance and Hatch |
|
||||
| Extra features | Selector and Customisation sync |
|
||||
| Advanced settings | Advanced, Power users, and Patches |
|
||||
| Help and information | Help and troubleshooting, and Change Log |
|
||||
|
||||
This prevents Obsidian from presenting them as one undifferentiated 'Detailed
|
||||
settings' continuation. Changing a setting which can move or reveal a page
|
||||
requests a catalogue refresh after persistence. External setting reloads use
|
||||
the same boundary. Constructing the definitions still performs no persistence,
|
||||
service, file, database, or network operation.
|
||||
|
||||
The imperative renderer uses the same stable distinction for its default-page
|
||||
selection: Quick Setup for an unconfigured installation and General for a
|
||||
configured installation. The landing composition is therefore a native 1.13
|
||||
improvement rather than a separate interpretation of synchronisation state on
|
||||
earlier supported Obsidian versions.
|
||||
|
||||
The custom `SettingPage` adapter class will be constructed lazily from the
|
||||
1.13-or-later path. `SettingPage` may remain a normal runtime import because the
|
||||
bundle reads Obsidian exports through its namespace object, but the import must
|
||||
not be subclassed or instantiated while the module is loading. The factory
|
||||
will first use `requireApiVersion("1.13.0")`, then verify that `SettingPage` is
|
||||
available. Older supported Obsidian versions therefore continue to call the
|
||||
imperative `display()` fallback without requiring a dynamic import or a
|
||||
polyfill for host behaviour which does not exist in those versions.
|
||||
|
||||
The adapter sets `title` from the catalogue and renders pane content into the
|
||||
host-provided `containerEl`. Its `hide()` boundary will unload the page-owned
|
||||
`Component`, unmount Svelte and markdown content, and remove page-owned update
|
||||
handlers. The parent tab's `hide()` remains a final cleanup boundary because
|
||||
Obsidian does not guarantee a page-level `hide()` call when the host window is
|
||||
destroyed.
|
||||
|
||||
Custom pages receive only the current page's `containerEl` and the existing
|
||||
`addPanel` helper. They do not recreate the old top-level tab menu inside each
|
||||
native page.
|
||||
|
||||
### Prefer native groups and searchable rows to full custom pages
|
||||
|
||||
An existing `addPanel` section maps naturally to a
|
||||
`SettingDefinitionGroup`. Within that group:
|
||||
|
||||
- ordinary value controls use `SettingSpec`;
|
||||
- a simple button operation may use `SettingDefinitionAction` directly;
|
||||
- a Svelte control or a specialised Obsidian row uses
|
||||
`SettingDefinitionRender` and returns its cleanup callback; and
|
||||
- a truly dynamic collection may use `SettingDefinitionList` when its existing
|
||||
behaviour already matches the list contract.
|
||||
|
||||
These Obsidian-specific definitions are written directly in the page adapter.
|
||||
They are not added to the shared `SettingSpec` vocabulary. This keeps the shared
|
||||
model small while allowing page, panel, and row names, descriptions, and aliases
|
||||
to participate in settings search.
|
||||
|
||||
Search indexes the metadata on a definition; it does not infer searchable
|
||||
entries from arbitrary DOM created inside a `render` callback. A whole panel
|
||||
wrapped in one rendered row therefore provides panel-level search only.
|
||||
Individual controls or actions require individual standard, action, or render
|
||||
definitions when control-level search is worthwhile.
|
||||
|
||||
A full custom `SettingPage` remains acceptable for a workflow which cannot yet
|
||||
be split without nesting several existing setting rows inside one synthetic
|
||||
row. It is a compatibility escape hatch, not the default representation for
|
||||
every complex pane.
|
||||
|
||||
### Keep `SettingSpec` intentionally small
|
||||
|
||||
`SettingSpec` describes only controls which have all of the following
|
||||
properties:
|
||||
|
||||
- one explicit persisted setting key, excluding keys from `OnDialogSettings`;
|
||||
- one standard toggle, number, or dropdown control in the first proof page;
|
||||
- a value which is read from the current editing buffer;
|
||||
- a change which can be persisted immediately through the existing
|
||||
`saveSettings([key])` path; and
|
||||
- no additional operation which must run after saving.
|
||||
|
||||
A representative, key-safe shape is:
|
||||
|
||||
```typescript
|
||||
type PersistedBooleanSettingKey = Exclude<AllBooleanItemKey, keyof OnDialogSettings>;
|
||||
type PersistedStringSettingKey = Exclude<AllStringItemKey, keyof OnDialogSettings>;
|
||||
type PersistedNumericSettingKey = Exclude<AllNumericItemKey, keyof OnDialogSettings>;
|
||||
type PersistedSettingKey = PersistedBooleanSettingKey | PersistedStringSettingKey | PersistedNumericSettingKey;
|
||||
|
||||
type SettingSpecBase<K extends PersistedSettingKey, C> = {
|
||||
key: K;
|
||||
control: C;
|
||||
visible?: () => boolean;
|
||||
disabled?: () => boolean;
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
type SettingSpec =
|
||||
| SettingSpecBase<PersistedBooleanSettingKey, { type: "toggle"; defaultValue?: boolean }>
|
||||
| SettingSpecBase<
|
||||
PersistedNumericSettingKey,
|
||||
{
|
||||
type: "number";
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowZero?: boolean;
|
||||
}
|
||||
>
|
||||
| SettingSpecBase<
|
||||
PersistedStringSettingKey,
|
||||
{
|
||||
type: "dropdown";
|
||||
options: () => Record<string, string>;
|
||||
}
|
||||
>;
|
||||
```
|
||||
|
||||
The initial union contains only the three control types used by the Advanced
|
||||
proof page. Text and textarea controls will be added when a migrated page
|
||||
provides a concrete use for them. Number validation is derived from `min`,
|
||||
`max`, and `allowZero`, so the native and imperative renderers enforce the same
|
||||
constraints without introducing an arbitrary validation language.
|
||||
|
||||
Names, descriptions, maturity labels, and placeholders come from the translated
|
||||
Commonlib setting metadata by default. The native renderer appends the existing
|
||||
maturity marker to `name`, and maps the description and supported placeholder
|
||||
directly. Configuration level remains a page and renderer concern: the legacy
|
||||
renderer retains its existing DOM classes, while the native page catalogue owns
|
||||
page-level visibility. A mixed-level native group must provide an explicit
|
||||
visibility predicate at that boundary rather than inferring one in the pure
|
||||
control converter. The specification may override a label only where the
|
||||
current interface already uses a deliberate product-specific label. Options
|
||||
remain LiveSync owned because they can depend on the active remote, platform,
|
||||
or language. A control which needs the current obsolete-row styling remains
|
||||
custom because the native definition does not provide an equivalent per-row
|
||||
class contract.
|
||||
|
||||
The catalogue explicitly lists each exposed key. It does not enumerate
|
||||
`SettingInformation` automatically.
|
||||
|
||||
The following behaviours are outside the standard specification and remain a
|
||||
custom row or custom page:
|
||||
|
||||
- `holdValue` and Apply buttons;
|
||||
- `invert` bindings;
|
||||
- password inputs;
|
||||
- a control which maps one displayed value to several stored keys, such as
|
||||
`syncMode`;
|
||||
- a control whose save effect cannot remain an explicit tab-owned handler;
|
||||
- button clusters, dynamic lists, Svelte components, rich diagnostic output,
|
||||
or destructive actions; and
|
||||
- styling which exists only to support the old wizard or tab menu.
|
||||
|
||||
This is a migration boundary, not a permanent prohibition. A second concrete
|
||||
use may justify a focused extension, but the first implementation will not add
|
||||
a generic action language, transaction language, or lifecycle hook system.
|
||||
|
||||
### Retain the existing editing and persistence owner
|
||||
|
||||
`ObsidianLiveSyncSettingTab` remains the owner of editing values and saves. The
|
||||
first implementation will expose a small adapter over its existing methods
|
||||
rather than move settings persistence into a new service.
|
||||
|
||||
For declarative controls:
|
||||
|
||||
- `getControlValue(key)` reads `editingSettings[key]`;
|
||||
- `setControlValue(key, value)` resolves an explicitly registered standard
|
||||
specification, updates the editing value, and calls `saveSettings([key])`;
|
||||
- successful saves continue to pass through `saveLocalSetting()` or
|
||||
`services.setting.saveSettingData()` as appropriate; and
|
||||
- the tab calls `refreshDomState()` after a value changes when another
|
||||
definition's `visible` or `disabled` predicate can depend on it.
|
||||
|
||||
An unknown key is an implementation error. The adapter must not fall through
|
||||
to `plugin.settings`, because LiveSync does not use that conventional storage
|
||||
shape.
|
||||
|
||||
Specification construction and `getSettingDefinitions()` must remain cheap
|
||||
and side-effect free. Obsidian calls the method during search indexing and
|
||||
again on updates; it must perform no file, database, network, or settings
|
||||
write.
|
||||
|
||||
### Register the setting tab after persisted settings load
|
||||
|
||||
The settings module registers its `PluginSettingTab` from the sequential
|
||||
`onSettingLoaded` lifecycle, not from `onInitialise`. Immediately before
|
||||
registration, it seeds the tab's editing and initial snapshots through
|
||||
`reloadAllSettings(true)`. Skipping the update request is intentional because
|
||||
the tab is not yet owned by Obsidian; `addSettingTab()` may request definitions
|
||||
immediately after this seeding step.
|
||||
|
||||
This lifecycle still precedes local database opening and replicator activation.
|
||||
Definition construction must therefore depend only on the seeded setting
|
||||
snapshot, static catalogue data, and translations. In particular, root-page
|
||||
ordering is based on the persisted `isConfigured` value. It must not inspect
|
||||
automatic synchronisation triggers, the active replicator, replication status,
|
||||
database readiness, files, or the network. Runtime operations remain explicit
|
||||
actions which run after the user selects them.
|
||||
|
||||
### Give imperative pages an explicit lifetime and refresh boundary
|
||||
|
||||
The present `display()` renders every pane together, so arrays of
|
||||
`settingComponents`, controlled DOM updates, and `onSavedHandlers` can be
|
||||
cleared and rebuilt as one unit. Native page navigation mounts one custom page
|
||||
at a time. Reusing those arrays without a page boundary would leak updates from
|
||||
a hidden page or remove effects which a staged edit still needs.
|
||||
|
||||
Each imperative render will therefore receive a small page scope containing:
|
||||
|
||||
- its `Component` lifetime;
|
||||
- its `LiveSyncSetting` instances;
|
||||
- its controlled DOM update functions; and
|
||||
- its explicit cleanup callbacks for Svelte, markdown, and other mounted
|
||||
content.
|
||||
|
||||
The legacy `display()` fallback uses one scope for the complete old tab. A
|
||||
custom declarative page creates one scope when opened and disposes it when
|
||||
hidden. This scope is renderer state and is not part of `SettingSpec`.
|
||||
Pane-construction callbacks which are queued by the existing helpers run only
|
||||
whilst the scope which requested them remains current. Closing or replacing a
|
||||
page therefore cannot attach delayed controls or cleanup callbacks to its
|
||||
successor.
|
||||
|
||||
Saved-setting effects remain owned by the tab session, not by a DOM page. The
|
||||
existing handlers are unique by setting key, so `addOnSaved()` will replace the
|
||||
handler for that key instead of appending duplicate closures when a page is
|
||||
reopened. A later migration may declare those effects in a separate catalogue,
|
||||
but they will not be added to the standard control specification merely to
|
||||
support page navigation.
|
||||
|
||||
Direct calls to `this.display()` from pane code and the tab's own reload path
|
||||
will be replaced by an explicit refresh request with one of two scopes:
|
||||
|
||||
- `page` re-renders the active custom page, or the legacy tab; and
|
||||
- `catalogue` calls the declarative tab's `update()` so translated page names,
|
||||
page visibility, and search definitions are rebuilt, or re-renders the
|
||||
legacy tab.
|
||||
|
||||
Changing the display language or the Advanced, Power User, or Edge Case mode
|
||||
uses a catalogue refresh. Dynamic Selector rows, Maintenance status, and
|
||||
Hidden File Sync status use a page refresh. This keeps the renderer choice out
|
||||
of pane actions and prevents a direct `display()` call from replacing native
|
||||
declarative navigation.
|
||||
|
||||
### Preserve imperative rendering as a renderer
|
||||
|
||||
The existing AutoWire calls are not the shared model. Instead,
|
||||
`LiveSyncSetting` becomes the legacy renderer for `SettingSpec` where a pane
|
||||
has migrated. It continues to own DOM classes, dirty-value decoration, and
|
||||
component updates for older Obsidian versions.
|
||||
|
||||
Unmigrated pane functions continue to call `LiveSyncSetting` directly inside a
|
||||
custom page. This allows incremental migration without first rewriting their
|
||||
behaviour.
|
||||
|
||||
The initial implementation must not modify Commonlib's setting metadata.
|
||||
Commonlib owns setting identity and shared labels; LiveSync owns page placement,
|
||||
Obsidian controls, persistence routing, and side effects.
|
||||
|
||||
### Use Advanced as the first proof page
|
||||
|
||||
The Advanced page is the first page whose groups contain only standard
|
||||
`SettingSpec` controls. It provides a useful proof without introducing
|
||||
unrelated workflows:
|
||||
|
||||
- number, dropdown, and toggle controls;
|
||||
- translated Commonlib labels;
|
||||
- minimum-value validation and a default value;
|
||||
- CouchDB-dependent visibility; and
|
||||
- configuration-level page visibility.
|
||||
|
||||
It has no current `onSaved` handler, Svelte component, staged Apply group, or
|
||||
destructive action. General was not the first proof because changing the
|
||||
display language re-renders the interface and other controls emit status events
|
||||
after saving. After the standard binding was proven, these effects remained
|
||||
explicit, tab-owned saved handlers while their one-key controls adopted
|
||||
`SettingSpec`.
|
||||
|
||||
The first native activation does not also divide other pages into searchable
|
||||
rows. It exposes their established pane renderers as custom pages, limited to
|
||||
page-level search. A later, optional migration can replace an individual custom
|
||||
page with standard, action, or rendered rows without changing the page
|
||||
catalogue.
|
||||
|
||||
## Implementation Stages and Checkpoint
|
||||
|
||||
### Stage A: remove the old wizard
|
||||
|
||||
Complete the focused prerequisite described above. This removes a DOM contract
|
||||
which would otherwise distort both renderers.
|
||||
|
||||
### Stage B: prove the shared standard-control model
|
||||
|
||||
The first declarative-settings change remains deliberately small. It will:
|
||||
|
||||
1. add the minimal `SettingSpec` type and pure conversion functions;
|
||||
2. describe only the Advanced controls as specifications;
|
||||
3. render those specifications through the existing `LiveSyncSetting` path;
|
||||
4. prove conversion to Obsidian setting definitions with focused tests; and
|
||||
5. retain the current `display()` behaviour, page menu, persistence owner, and
|
||||
`minAppVersion`, without returning non-empty setting definitions.
|
||||
|
||||
Stage B does not enable the native declarative renderer. It proves that the
|
||||
shared model can express a real page without first taking ownership of every
|
||||
page's lifetime. Returning an empty definition array merely to silence review
|
||||
output is not an outcome of this stage.
|
||||
|
||||
### Stage C1: activate the native catalogue
|
||||
|
||||
Activation is a separate checkpoint because it is the first cross-cutting
|
||||
change. It will add the page catalogue, custom `SettingPage` adapter, scoped
|
||||
imperative lifetime, renderer-neutral refresh operation, declarative control
|
||||
read and write overrides, and non-empty definitions on Obsidian 1.13 or later.
|
||||
|
||||
The non-empty definition array replaces `display()` completely. Partial
|
||||
activation is therefore not safe: all 12 existing pages must enter the native
|
||||
catalogue together. Advanced is the only page represented by native groups in
|
||||
this stage. The other 11 pages use their existing pane renderers inside lazy
|
||||
custom pages. Obsidian versions before 1.13 retain the complete imperative
|
||||
renderer and its menu.
|
||||
|
||||
The existing rebuild-required action remains available while navigating native
|
||||
pages. Custom pages render the established action at their page boundary, and
|
||||
the Advanced definition includes an equivalent action item whose visibility is
|
||||
derived from the same dirty-state predicate. Both forms call the existing
|
||||
`confirmRebuild()` owner rather than introducing another apply workflow.
|
||||
|
||||
This stage necessarily touches direct `display()` callers, saved-handler
|
||||
ownership, and cleanup for Svelte and markdown content. It does not expand
|
||||
`SettingSpec` to absorb those concerns merely to make activation appear
|
||||
smaller.
|
||||
|
||||
### Stage C2: improve search coverage selectively
|
||||
|
||||
After activation, an individual custom page may be replaced with native groups,
|
||||
actions, and rendered rows where the existing panel boundary maps cleanly to
|
||||
Obsidian's definitions. The bounded improvement converts the General and
|
||||
Logging controls and the simple Quick Setup actions, then organises Appearance,
|
||||
Logging, and Extra menus as child pages of General Settings. It also removes
|
||||
the now-misleading Setup child page and assigns its remaining responsibilities
|
||||
to their existing owners: Extra menus, Maintenance, and Help and
|
||||
troubleshooting. Further conversions remain optional follow-up work rather than
|
||||
a condition of Stage C1. Complex workflows may remain custom pages indefinitely.
|
||||
|
||||
Stage C2 must not introduce a general action or lifecycle language. Each page
|
||||
conversion should be justified by useful settings-search coverage and retain
|
||||
the catalogue, persistence owner, and refresh boundaries established by Stage
|
||||
C1.
|
||||
|
||||
### Centralise initialisation for settings pending application
|
||||
|
||||
Settings which cannot take effect safely through immediate persistence remain
|
||||
in the settings tab's editing buffer. Their Apply action delegates to a focused
|
||||
`SetupManager` dialogue which asks whether the next start should use existing
|
||||
synchronisation data or the files in the current Vault. `SetupManager` reports
|
||||
user cancellation and validation or reservation failure distinctly instead of
|
||||
collapsing them into a boolean setup outcome. A settings-persistence exception
|
||||
still propagates after `Rebuilder` removes the reserved flag.
|
||||
|
||||
`Rebuilder.scheduleFetch()` and `Rebuilder.scheduleRebuild()` are the only
|
||||
owners of the corresponding flag files. They reserve the next-start operation
|
||||
before the callback persists the edited settings, remove the flag if that
|
||||
callback fails, and request the restart only after preparation succeeds. The
|
||||
settings tab must not write those flag files or request the restart directly.
|
||||
|
||||
A user cancellation returns to a separate confirmation which offers either to
|
||||
keep editing or to apply the settings without initialisation. This preserves
|
||||
the former advanced fallback without presenting it as an equal data-source
|
||||
choice. A validation or flag-reservation failure is not a cancellation and
|
||||
must not offer that bypass. The pending action is present on the native root
|
||||
settings page as well as within custom and native child pages.
|
||||
|
||||
## Verification
|
||||
|
||||
Stage A runs the maintained onboarding E2E scenario and an ordinary settings
|
||||
navigation scenario. A source check confirms that no old wizard event, state,
|
||||
class, or message consumer remains.
|
||||
|
||||
Stage B focused unit tests verify:
|
||||
|
||||
- only explicitly listed Advanced controls become specifications;
|
||||
- synthetic `OnDialogSettings` keys cannot be standard specifications;
|
||||
- control type, options, defaults, validation, metadata, and visibility map
|
||||
consistently to the legacy and native representations; and
|
||||
- rendering the Advanced specifications through `LiveSyncSetting` preserves
|
||||
the current save behaviour.
|
||||
|
||||
Stage C1 and the landing-page focused unit tests verify:
|
||||
|
||||
- the page catalogue contains all 13 pane-based destinations with stable, unique
|
||||
identifiers and names;
|
||||
- Appearance, Logging, Extra menus, and Advanced are native-items child pages,
|
||||
and ten child pages retain custom factories;
|
||||
- configured and unconfigured installations use their specified landing-page
|
||||
order regardless of transient replication status;
|
||||
- definition construction does not request the active replicator before the
|
||||
database is ready;
|
||||
- the settings tab is registered only after persisted settings load, and its
|
||||
editing snapshot is seeded before registration without requesting a render;
|
||||
- Remote Configuration and Sync Settings remain native navigable pages inside
|
||||
the separate Synchronisation group;
|
||||
- maintenance, extra features, advanced settings, and help have explicit page
|
||||
groups, and the old Setup child page is absent;
|
||||
- all eight General and Logging controls are registered once, with their
|
||||
existing conditional visibility;
|
||||
- the three Extra menus controls are registered once and refresh page
|
||||
visibility after persistence;
|
||||
- every standard setting key is registered once;
|
||||
- a setting pending application exposes its Apply action on the native root
|
||||
page;
|
||||
- cancelling the initialisation choice preserves the editing buffer, while the
|
||||
separately confirmed settings-only path persists it;
|
||||
- Fetch and Rebuild reserve their flag through `Rebuilder` before pending
|
||||
settings are persisted, and a reservation failure leaves them unapplied;
|
||||
- reads use the editing buffer;
|
||||
- writes use `saveSettings([key])` and never `plugin.settings`;
|
||||
- custom pages dispose their page-owned resources and do not duplicate saved
|
||||
handlers when reopened; and
|
||||
- importing and opening the imperative fallback does not evaluate or require
|
||||
`SettingPage` on Obsidian before 1.13.
|
||||
|
||||
Real-Obsidian verification on 1.13 or later confirms:
|
||||
|
||||
- the common landing controls and actions render before native page navigation;
|
||||
- the Quick Setup action opens the maintained onboarding dialogue;
|
||||
- Remote Configuration remains visible without initial scrolling in mobile
|
||||
test mode;
|
||||
- native page navigation opens every remaining child page;
|
||||
- Advanced controls appear in global settings search;
|
||||
- Advanced values persist and are restored after reopening settings;
|
||||
- CouchDB-dependent controls and Advanced-mode visibility update correctly;
|
||||
- a representative custom page, including its cleanup, still works;
|
||||
- page and catalogue refreshes preserve native navigation and the
|
||||
rebuild-required action; and
|
||||
- no duplicate save, update handler, or saved-setting effect occurs after
|
||||
leaving and reopening a page.
|
||||
|
||||
The maintained real-Obsidian settings scenario also changes a setting which
|
||||
remains pending until initialisation, captures the source-choice and
|
||||
settings-only fallback dialogues, and confirms that keeping the setting
|
||||
pending does not persist it. It mounts the P2P variant directly to confirm that
|
||||
it offers a source device and local Vault preparation without presenting a
|
||||
central-server overwrite operation.
|
||||
|
||||
Because the manifest continues to support earlier Obsidian versions, a
|
||||
pre-1.13 real-runtime smoke test must confirm that the imperative fallback
|
||||
still opens, navigates, saves one Advanced value, and opens one custom page. If
|
||||
the maintained E2E runner cannot install that runtime, the exact manual version
|
||||
and procedure must be recorded before the implementation is merged.
|
||||
|
||||
The current real-Obsidian runner defaults to Obsidian 1.12.7, so it owns the
|
||||
fallback smoke path. The declarative path uses a separately installed
|
||||
1.13-or-later AppImage selected through `OBSIDIAN_BINARY` and `OBSIDIAN_CLI`.
|
||||
`E2E_OBSIDIAN_SETTINGS_ONLY=true` limits that run to the settings contract so
|
||||
the same scenario can validate a second Obsidian runtime without repeating its
|
||||
unrelated compatibility-review and mobile-layout coverage.
|
||||
|
||||
Existing E2E scenarios must use one shared settings-page navigation helper.
|
||||
That helper uses the current `.sls-setting-menu-btn` contract on the legacy
|
||||
runtime and accessible native page names on 1.13 or later. Individual scenarios
|
||||
must not duplicate version checks or retain selectors for a menu which the
|
||||
declarative renderer does not create.
|
||||
|
||||
The initial Stage C1 implementation was exercised against the official Obsidian
|
||||
1.13.4 arm64 AppImage with SHA-256
|
||||
`20d0b13c6d40bb3d7e73d9b4be6d2e21dfcc145b2106a747d0c1b81e651dabfe`.
|
||||
That run opened all 12 pages from the native page catalogue, found the Advanced
|
||||
control through global settings search, persisted a numeric value on Enter,
|
||||
and restored it after the settings dialogue was closed and reopened. The
|
||||
complete default scenario also passed on Obsidian 1.12.7, including
|
||||
compatibility review, mobile layout, imperative page navigation, and immediate
|
||||
persistence of the same Advanced value. The shared E2E navigator owns both the
|
||||
separate settings renderer used by Obsidian 1.13 and the legacy
|
||||
`.sls-setting-menu-btn` interface.
|
||||
|
||||
Before the start-up lifecycle correction, the Stage C2 landing composition was
|
||||
exercised on Obsidian 1.13.4 with a configured installation whose automatic
|
||||
synchronisation triggers were disabled. Under the former predicate, the real
|
||||
interface rendered Quick Setup, a separate Synchronisation group containing
|
||||
Remote Configuration and Sync Settings, and a General Settings group containing
|
||||
Appearance, Logging, and Extra menus in that order. It opened all 14 nested
|
||||
settings pages, found the Advanced control through global settings search, and
|
||||
restored its saved value after reopening settings. In mobile test mode, Remote
|
||||
Configuration remained inside the initial viewport below the two Quick Setup
|
||||
actions and the Synchronisation heading. The complete scenario also passed with
|
||||
the same bundle on Obsidian 1.12.7, confirming that the imperative fallback
|
||||
retained its navigation and save behaviour.
|
||||
|
||||
The start-up lifecycle correction was subsequently exercised with the same
|
||||
official Obsidian 1.13.4 build. The settings scenario captured and verified the
|
||||
exact configured and unconfigured root-group orders, including Set up other
|
||||
devices before Quick Setup for a configured installation. The same bundle
|
||||
opened General Settings by default through the imperative fallback on Obsidian
|
||||
1.12.7. Focused unit tests own the earlier lifecycle boundary: persisted
|
||||
settings are copied before registration, and definition construction does not
|
||||
request an active replicator.
|
||||
|
||||
## Expansion Checkpoints
|
||||
|
||||
Review the scope with the maintainer before any implementation adds one of the
|
||||
following:
|
||||
|
||||
- a generic representation of actions, confirmations, rebuilds, or service
|
||||
lifecycles;
|
||||
- staged multi-setting transactions in `SettingSpec`;
|
||||
- a replacement for the current onboarding workflow;
|
||||
- a Commonlib setting metadata contract change;
|
||||
- a minimum Obsidian version increase; or
|
||||
- further conversion of Remote Configuration, Hatch, Maintenance, Help, or the
|
||||
Svelte-based Selector controls.
|
||||
|
||||
These may become worthwhile after the first proof, but none is required to
|
||||
establish a shared standard-control model and native settings search.
|
||||
|
||||
## Alternatives Rejected
|
||||
|
||||
### Return an empty definition array
|
||||
|
||||
This can satisfy a syntactic lint check while retaining `display()`, but it
|
||||
does not add native settings search or prove a migration path.
|
||||
|
||||
### Generate every setting from Commonlib setting metadata
|
||||
|
||||
Metadata does not define current page membership, control type, options,
|
||||
visibility, save policy, or whether a compatibility key should be exposed.
|
||||
Automatic generation would expose settings which the current interface omits.
|
||||
|
||||
### Teach `LiveSyncSetting` to run against a simulated DOM
|
||||
|
||||
The existing class is a renderer with direct component and element access.
|
||||
Making it emulate declarative output would preserve its mixed responsibilities
|
||||
and make the new API depend on implementation details of the old one.
|
||||
|
||||
### Model every pane before adopting the API
|
||||
|
||||
This would require a general action and lifecycle language for approximately
|
||||
50 buttons, several dynamic lists, five Svelte-based regular-expression
|
||||
controls, and multiple recovery workflows. The resulting framework would be
|
||||
larger than the standard-control problem it is intended to solve.
|
||||
|
||||
## References
|
||||
|
||||
- [Migrate to declarative settings](https://docs.obsidian.md/plugins/guides/migrate-declarative-settings)
|
||||
- Obsidian `PluginSettingTab`, `SettingDefinitionItem`, and `SettingPage` type
|
||||
declarations from the dependency version locked by this repository
|
||||
@@ -0,0 +1,179 @@
|
||||
# Architectural Decision Record: P2P Transport Compatibility Controls
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — the user-facing controls will be introduced in stages. This record defines their boundaries before Commonlib settings and LiveSync interfaces are changed.
|
||||
|
||||
## Context
|
||||
|
||||
WebRTC connectivity depends on both devices, their browsers or embedded WebViews, NAT behaviour, carrier networks, VPNs, firewalls, and the path between them. A configuration which works on desktop Wi-Fi may fail on a mobile carrier, and moving the same devices through a mesh VPN may change the result without changing LiveSync.
|
||||
|
||||
The current P2P transport has several relevant properties:
|
||||
|
||||
- Trystero supplies the Nostr signalling strategy and the browser-owned WebRTC connection.
|
||||
- Commonlib limits one RPC wire payload to 15,360 bytes so it remains below Trystero's own action-chunk boundary.
|
||||
- Trystero supplies ordinary STUN servers and accepts an optional TURN server list with one username and credential.
|
||||
- ICE chooses a direct, server-reflexive, or TURN-relayed path automatically.
|
||||
- Commonlib can collect raw WebRTC statistics, but LiveSync does not yet present the selected candidate route in a concise diagnostic result.
|
||||
|
||||
Issue reports suggest that reducing the application payload may improve some mobile and constrained-network paths. A VPN such as Tailscale may also turn an unreliable route into a reliable one. These observations are consistent with NAT, path-MTU, fragmentation, or intermediary behaviour, but they do not prove one universal cause. Browser WebRTC implementations retain responsibility for SCTP, DTLS, ICE, packetisation, congestion control, and retransmission.
|
||||
|
||||
One low-level number cannot represent all of these concerns. Users need a small set of meaningful compatibility choices, while transport-internal controls which cannot be selected safely should remain implementation details.
|
||||
|
||||
## Decision
|
||||
|
||||
### Message-size presets
|
||||
|
||||
LiveSync will expose a `P2P message size` choice with four presets:
|
||||
|
||||
| Label | Maximum RPC wire payload | Intended use |
|
||||
| ----------------------- | -----------------------: | ------------------------------------------------------------------------------- |
|
||||
| `Standard` | 15,360 bytes | Existing default and best throughput. |
|
||||
| `Reduced` | 2,048 bytes | First compatibility step for an unreliable path. |
|
||||
| `Conservative` | 1,024 bytes | Stronger compatibility at greater framing and processing cost. |
|
||||
| `Maximum compatibility` | 800 bytes | Most conservative offered value for paths suspected of dropping larger packets. |
|
||||
|
||||
This value limits Commonlib RPC wire payloads before Trystero applies its own framing. It is not a LiveSync file Chunk size, an IP MTU, an SCTP fragment size, or a guarantee that lower layers will avoid fragmentation. The smaller presets reduce the amount presented to the transport at once and trade throughput for compatibility.
|
||||
|
||||
The bound applies to outgoing messages. A device which only lowers its own value still receives messages produced under the sender's value. The selected preset therefore belongs to the P2P profile and is included in an encrypted Setup URI for additional devices. A device which was configured earlier must be changed separately; the interface and troubleshooting guidance must state that the same conservative preset should be selected on every participating device. An absent key preserves the current 15,360-byte default.
|
||||
|
||||
Automatic negotiation or fallback between presets is deferred. A failed ordered data channel may require connection replacement before a smaller retry can prove anything, and changing transport parameters during a replication session would broaden the lifecycle contract considerably. The first implementation remains explicit, stable for one room lifetime, and inspectable.
|
||||
|
||||
### Connection path
|
||||
|
||||
LiveSync will expose a separate `Connection path` choice:
|
||||
|
||||
- `Automatic` retains normal ICE selection and is the default.
|
||||
- `TURN relay only` supplies `iceTransportPolicy: 'relay'` and prevents direct or server-reflexive candidates from being selected.
|
||||
|
||||
`TURN relay only` is enabled only when at least one syntactically valid `turn:` or `turns:` URL is configured. If the last valid TURN URL is removed while relay-only mode is selected, the dialogue restores `Automatic` and displays a concise explanation.
|
||||
|
||||
The route policy is an ordinary P2P profile property. It is retained in P2P connection strings and encrypted Setup URIs so that an imported compatibility profile has reproducible transport behaviour.
|
||||
|
||||
Multiple P2P profiles may intentionally use the same Group ID, passphrase, and relay list while selecting different compatibility settings. For example, one profile may use `Standard` and `Automatic`, while another uses `Maximum compatibility` and `TURN relay only`. Only the selected P2P profile joins the group, so each device can select the profile appropriate to its current network without a separate device-local override system.
|
||||
|
||||
No `Direct only` choice will be added. `Automatic` already prefers viable non-relayed candidates, and preventing TURN fallback would mainly create another failure mode.
|
||||
|
||||
### TURN server presentation
|
||||
|
||||
The first settings revision retains the existing storage and dialogue contract of one comma-separated TURN URL list, one username, and one credential. The connection-path choice is presented separately under `Connection compatibility`, while the TURN values remain under `Advanced Settings`.
|
||||
|
||||
A future interface may present the existing comma-separated value as ordered `turn:` and `turns:` URL rows without changing its serialised representation. A structured list of multiple credential profiles is deferred until a provider or self-hosted use case requires different credentials in the same P2P profile.
|
||||
|
||||
Static long-term credentials are the supported first stage. Managed providers may return short-lived credentials, but LiveSync must not store a provider API token or a Coturn shared authentication secret. A future managed-credential design needs a separately trusted HTTPS endpoint, expiry handling, refresh behaviour, failure reporting, and a clear Setup URI policy. It is not represented as another static password field.
|
||||
|
||||
### TURN allocation check and route diagnostics
|
||||
|
||||
A future `Test TURN server` action should create a disposable WebRTC check with `iceTransportPolicy: 'relay'`, request candidate gathering, and require at least one relay candidate. It must not read a Vault, join a LiveSync P2P room, or claim that document synchronisation has succeeded.
|
||||
|
||||
Where the browser exposes the evidence, the result should report:
|
||||
|
||||
- whether a relay candidate was gathered;
|
||||
- the TURN URL used for that candidate;
|
||||
- UDP, TCP, or TLS transport; and
|
||||
- a bounded failure or inconclusive result.
|
||||
|
||||
Ordinary P2P diagnostics should later summarise the selected candidate pair as direct, server-reflexive, or relayed, with its transport. Raw `getStats()` output remains supporting evidence rather than the primary interface.
|
||||
|
||||
### Placement and defaults
|
||||
|
||||
These controls belong inside `P2P Configuration` under a `Connection compatibility` section. They do not require the repository-wide Advanced, Power User, or Edge Case modes. P2P itself remains a supported opt-in feature.
|
||||
|
||||
Existing profiles retain the following defaults:
|
||||
|
||||
- `P2P message size`: `Standard`;
|
||||
- `Connection path`: `Automatic`; and
|
||||
- TURN credentials and URLs: unchanged.
|
||||
|
||||
Settings which replace a room continue to use the established P2P room and transport lifecycle. No new reconnect interval, handshake timeout, keepalive interval, trickle-ICE, candidate-pool, data-channel reliability, or backpressure setting is exposed.
|
||||
|
||||
## Self-hosted TURN example
|
||||
|
||||
The repository supplies an optional Coturn Compose example under `docker/coturn/`. It uses a versioned upstream `coturn/coturn` image rather than maintaining another LiveSync Dockerfile.
|
||||
|
||||
The example deliberately covers one small static-credential deployment:
|
||||
|
||||
- Linux host networking, which avoids Docker's large port-range forwarding cost;
|
||||
- TURN over UDP and TCP on port 3478;
|
||||
- a bounded UDP relay port range;
|
||||
- explicit long-term credentials;
|
||||
- an explicit public IPv4 address;
|
||||
- no TLS or DTLS in the starter configuration; and
|
||||
- restrictions which prevent relaying to common private IPv4 ranges.
|
||||
|
||||
The starter does not recommend `turns:` on port 443. It conflicts with an HTTPS entry point which already owns the same IP address and TCP port, including the bundled CouchDB Caddy profile. When a restrictive network requires this path, the preferred deployment uses a separate TURN host or public IP address.
|
||||
|
||||
An outbound tunnel used for CouchDB may leave the host's public port 443 available when TURN uses a separate DNS record which resolves directly to that host, but the tunnel itself cannot carry TURN traffic. A layer-4 TLS router can also own the shared port and select separate CouchDB and TURN backends by SNI. That alternative adds certificate and routing responsibilities, depends on the intended TURN clients supplying usable SNI, and is outside the supplied Compose example. The standard Caddy image used by the CouchDB profile does not provide that layer-4 routing.
|
||||
|
||||
TURN over TLS is not HTTP and must reach Coturn directly or through a compatible layer-4 proxy. Supporting it also adds private-key, renewal, privileged-port, and real-network verification responsibilities.
|
||||
|
||||
The Compose example is not a hosted service supplied by the project, an availability guarantee, or a substitute for firewall and abuse controls. Operators remain responsible for DNS, certificates when enabled, port forwarding, bandwidth, quotas, monitoring, software updates, credential rotation, and legal or provider constraints.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
TURN relays the already encrypted WebRTC connection. A TURN operator cannot read LiveSync's end-to-end encrypted Vault contents, but can observe endpoint addresses, timing, traffic volume, and service credentials.
|
||||
|
||||
Static credentials allow use of the operator's bandwidth until they are changed. They should be unique, high entropy, and limited to the intended deployment. Setup URIs are encrypted but still contain the P2P connection profile; they and their separate passphrases must be protected.
|
||||
|
||||
The Coturn Docker example uses environment interpolation for its static credential. A local Docker administrator can inspect the resulting container arguments and already has equivalent control of that host. The `.env` file remains untracked and should be readable only by the operator.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Expose a free-form byte field
|
||||
|
||||
Most users cannot infer a safe application payload from a network MTU, and an arbitrary value makes reports difficult to compare. Four named presets provide a bounded troubleshooting ladder.
|
||||
|
||||
### Apply the smaller payload only on the affected mobile device
|
||||
|
||||
The bound controls outgoing messages. This would leave larger messages from another sender unchanged and could fail during the direction which matters most for an initial fetch.
|
||||
|
||||
### Force TURN whenever a TURN server is configured
|
||||
|
||||
TURN is normally a fallback. Forcing it by default adds latency and bandwidth cost, and exposes more connection metadata even when a direct path works.
|
||||
|
||||
### Store the connection path in a device-local overlay
|
||||
|
||||
A second layer of device-specific profile overrides would make imported profile behaviour less reproducible and add another identity, mapping, and lifecycle contract. Separate named P2P profiles already let each device select an explicit transport policy, including when those profiles share the same Group ID and credentials.
|
||||
|
||||
### Automatically decrease the payload after a transfer failure
|
||||
|
||||
A transfer failure does not identify message size as the cause. Reusing a possibly wedged ordered channel would also make the retry inconclusive, while rebuilding the connection expands the lifecycle and user-notification design.
|
||||
|
||||
### Add browser-specific defaults
|
||||
|
||||
Safari, mobile Safari, Chrome, and Chrome on Android use different platform WebRTC implementations and lifecycle policies, but the failing route also depends on both networks and the remote peer. There is not enough stable evidence for a browser-name heuristic. Explicit cross-platform presets are more predictable.
|
||||
|
||||
### Build and maintain a LiveSync Coturn image
|
||||
|
||||
The upstream project already publishes a multi-platform image and documents its configuration contract. A local Dockerfile would duplicate security updates and release work without adding a LiveSync-specific server component.
|
||||
|
||||
### Bundle a shared port-443 router
|
||||
|
||||
A layer-4 TLS router could share one public address between distinct CouchDB and TURN hostnames by inspecting SNI. Bundling that topology would replace the current Caddy ownership of port 443, add another certificate and routing lifecycle, and rely on the intended TURN clients presenting usable SNI. A separate TURN host or public IP address keeps those failure and ownership boundaries explicit.
|
||||
|
||||
## Verification
|
||||
|
||||
The first implementation stage must add focused tests before production changes:
|
||||
|
||||
- settings-schema defaults for absent keys;
|
||||
- P2P connection-string and Setup URI round trips which retain both transport compatibility settings;
|
||||
- compatibility parsing and serialisation of the existing TURN URL string;
|
||||
- mapping each message-size preset to the exact Commonlib wire bound;
|
||||
- mapping relay-only mode to `iceTransportPolicy: 'relay'`;
|
||||
- rejection or automatic reset of relay-only mode without a valid TURN URL;
|
||||
- room replacement after either effective transport setting changes; and
|
||||
- the real Obsidian dialogue, profile, and connection-string round trip.
|
||||
|
||||
The future TURN allocation action requires its own focused tests using injected WebRTC boundaries, followed by a real transport test only for the device- or network-owned behaviour which deterministic injection cannot prove.
|
||||
|
||||
The Coturn example is checked independently with `docker compose config`. Runtime verification uses a real Coturn allocation from outside the server network and confirms both UDP and TCP client paths before it is presented as a known-working deployment.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users gain a small compatibility ladder without learning WebRTC internals.
|
||||
- A conservative message size affects throughput wherever it is selected or imported, and must be applied to every participating device to protect all transfer directions.
|
||||
- Profiles may intentionally share the same P2P group identity while offering different transport compatibility choices; the selected profile determines the active connection behaviour.
|
||||
- TURN can be forced for diagnosis or hostile networks without making relay use the global default.
|
||||
- Static and managed TURN credentials have separate, explicit responsibility boundaries.
|
||||
- Browser-specific heuristics, automatic payload fallback, and low-level transport knobs remain out of scope.
|
||||
- A reproducible self-hosted starter is available without making LiveSync responsible for a separate TURN image.
|
||||
@@ -0,0 +1,797 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 1: Core Contract
|
||||
|
||||
Series navigation: this is Part 1 of 3. Continue with [Part 2: P2P service and
|
||||
session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md),
|
||||
then [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md).
|
||||
|
||||
## Status
|
||||
|
||||
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 the completed implementation sequence lives in Part
|
||||
3. The current structure is summarised in the
|
||||
[Replicator architecture](../design_docs/replicator_architecture.md) design
|
||||
document.
|
||||
|
||||
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
|
||||
|
||||
Self-hosted LiveSync currently represents CouchDB, Object Storage, and P2P with
|
||||
one `LiveSyncAbstractReplicator` base class. That base class requires finite
|
||||
and continuous replication, full upload and download, remote creation and
|
||||
reset, lock administration, preferred-tweak Metadata, on-demand Chunk reads,
|
||||
remote status, integrity inspection, and connected-device inspection.
|
||||
|
||||
These operations do not share one support boundary:
|
||||
|
||||
- CouchDB supports unattended OneShot Sync, Continuous replication,
|
||||
central-remote administration, and CouchDB-specific inspection and
|
||||
maintenance.
|
||||
- Object Storage supports finite journal synchronisation, central reset and
|
||||
lock Metadata, full upload and download, and storage-size inspection. It has
|
||||
no continuous changes feed, CouchDB Chunk source, CouchDB integrity
|
||||
inspection, or CouchDB device registry.
|
||||
- P2P supports peer-targeted finite transfer and an independently owned room,
|
||||
signalling, watch, and broadcast lifecycle. It has no central database to
|
||||
create, reset, lock, inspect for size, or upload during first-device setup.
|
||||
It also has peer-driven AutoSync and AutoWatch paths when the room is open.
|
||||
Those paths are detailed in Part 2.
|
||||
|
||||
The abstract class makes absent capabilities look like operations. Current
|
||||
implementations express absence through thrown errors, `false`, empty arrays,
|
||||
zero counts, silent success, and a dummy all-zero Security Seed. Callers
|
||||
cannot tell whether an operation was performed, was inapplicable, or could not
|
||||
be performed.
|
||||
|
||||
A neutral value is correct only when it is the documented identity for every
|
||||
caller. For example, Object Storage has no remote-Chunk role, whereas a
|
||||
supported CouchDB Chunk request may legitimately return an empty array. A
|
||||
zero integrity count cannot safely stand for an inspection which did not run.
|
||||
|
||||
Issue 1140 exposes the lifecycle consequence. In version 1.0.21,
|
||||
`ModuleReplicatorCouchDB` owns the application resume callback and excludes
|
||||
Object Storage and P2P by `remoteType`. Object Storage accepts `syncOnStart`,
|
||||
but no journal synchronisation starts at resume. Periodic, event-driven, and
|
||||
manual calls later reach the active Replicator successfully.
|
||||
|
||||
The construction boundary is also overloaded. `getNewReplicator()` is an
|
||||
order-dependent first-result handler which ignores false results and catches
|
||||
handler errors. It is used for active Replicator acquisition, trial settings,
|
||||
and temporary command instances. P2P construction can replace and close a
|
||||
service-owned current transport, so an apparently temporary request can
|
||||
disturb an active or adjunct transport.
|
||||
|
||||
Fast Setup is a separate boundary. Streaming Fetch is a CouchDB-specific
|
||||
initial transfer which uses CouchDB HTTP settings and a Security Seed supplier;
|
||||
it does not need a full Replicator or an owned PouchDB connection. It must not
|
||||
be made a generic Replicator capability merely because the current code obtains
|
||||
one as a supplier.
|
||||
|
||||
Finally, current operation results can hide failure. Object Storage can discard
|
||||
a failed or stopped journal result and report success. A headless P2P path can
|
||||
complete but return `undefined`, which the caller interprets as failure. Remote
|
||||
mutations can be caught or ignored before Rebuilder continuation, and an
|
||||
offline integrity inspection can be represented as zero. The contract must
|
||||
make those outcomes truthful.
|
||||
|
||||
## Decision drivers
|
||||
|
||||
The design must:
|
||||
|
||||
1. cover every operation used by the plug-in, CLI, WebApp, WebPeer, Setup,
|
||||
Rebuilder, and maintenance flows;
|
||||
2. keep lifecycle policy independent of provider class names;
|
||||
3. make an omitted support decision a compile-time error when a current
|
||||
provider or capability is added;
|
||||
4. carry trigger and interaction policy through `ReplicationService`, so an
|
||||
automatic trigger cannot open a dialogue;
|
||||
5. distinguish capability absence, unavailable observation, and an observed
|
||||
empty value where that difference affects safety;
|
||||
6. retain simple neutral results where they are safe identities for every
|
||||
caller;
|
||||
7. give active Replicator, flow-specific probe, and transport resources one
|
||||
explicit owner and disposal boundary; and
|
||||
8. report replication, central-remote mutation, and maintenance outcomes
|
||||
truthfully while preserving source compatibility during migration.
|
||||
|
||||
## Decision
|
||||
|
||||
### Use precise ownership terms
|
||||
|
||||
- A **provider definition** is the host-composed, exhaustive declaration for
|
||||
one current remote kind.
|
||||
- The **active Replicator** is the selected main-remote handle owned by
|
||||
`ReplicatorService`.
|
||||
- A **probe** is a short-lived, flow-specific validation resource whose caller
|
||||
owns disposal.
|
||||
- The **P2P service** is the stable Commonlib implementation which supplies
|
||||
narrow P2P contract views. Its room-session ownership is defined in Part 2.
|
||||
- A **room session** and **session epoch** are P2P terms defined in Part 2; an
|
||||
epoch is an internal fence, not a public capability or logical room name.
|
||||
|
||||
An active Replicator is a handle, not necessarily the owner of every transport
|
||||
which it uses. In particular, the active P2P Replicator is a non-owning adapter
|
||||
over the P2P service. Its ownership consequences are specified once in Part 2.
|
||||
|
||||
### Separate policy, provider selection, and the active Replicator
|
||||
|
||||
Application lifecycle policy decides **when** synchronisation is requested.
|
||||
The selected provider and its active Replicator decide **how**, and whether,
|
||||
that request can be performed. A provider module must not subscribe to the
|
||||
application resume lifecycle merely because it can construct a transport.
|
||||
|
||||
Self-hosted LiveSync will compose one LiveSync-owned serviceFeature as the
|
||||
replication scheduling boundary. The serviceFeature creates one private
|
||||
scheduling context and passes it to module-level transition functions. The
|
||||
context contains only scheduling state and narrow collaborators; the functions
|
||||
implement external-poller ownership, Continuous ownership of recurring work,
|
||||
the daemon's satisfied initial OneShot marker, resume coalescing, and
|
||||
periodic-timer reconciliation. They do not register handlers or acquire
|
||||
`LiveSyncBaseCore`.
|
||||
|
||||
The context receives narrow collaborators for readiness and suspension queries,
|
||||
current settings, `ReplicationService`, periodic-timer control, and diagnostic
|
||||
logging. The surrounding serviceFeature owns the context lifetime, lifecycle
|
||||
registration, and adaptation from those Services. It returns a focused control
|
||||
view containing only the daemon operations to select external polling and mark
|
||||
the initial OneShot as satisfied. Core construction passes a frozen bundle of
|
||||
built-in feature views to host composition, without retaining those views as
|
||||
public `LiveSyncBaseCore` properties. The CLI injects the scheduling view into
|
||||
its command context; other hosts may ignore it. No host may expose the context's
|
||||
mutable state or recover it from a core-keyed global or `WeakMap`.
|
||||
|
||||
This boundary is not a ServiceModule merely because it owns state. It neither
|
||||
owns a shared external resource nor supplies a general operational capability
|
||||
to several unrelated consumers. If a future consumer needs a stable shared
|
||||
scheduling capability beyond the focused CLI view, that ownership decision
|
||||
must be reviewed explicitly rather than widening the returned view implicitly.
|
||||
|
||||
The scheduling functions use persisted settings, `ReplicationService`, and
|
||||
the active support declaration. They do not branch on `remoteType` or use
|
||||
`instanceof` as a capability test. Commonlib owns the trigger-aware replication
|
||||
contract; the host owns application lifecycle wiring.
|
||||
|
||||
The existing `onResumed` event remains the eligible-resume boundary after
|
||||
initial readiness, settings application, and visibility recovery. It is not
|
||||
redefined as a once-per-process event. The context-backed functions coalesce
|
||||
duplicate work within one lifecycle generation and preserve readiness and
|
||||
suspension gates:
|
||||
|
||||
- configured Continuous replication starts only through an active Continuous
|
||||
role;
|
||||
- otherwise, configured `syncOnStart` runs through an unattended OneShot role
|
||||
when that role is supported;
|
||||
- an unsupported configured policy produces an explicit unsupported or
|
||||
not-implemented result; and
|
||||
- automatic start-up, periodic, file-event, and merge triggers never open a
|
||||
dialogue. A target-requiring operation is available to an explicit user
|
||||
action or to a flow with a configured target.
|
||||
|
||||
`ReplicationService` remains responsible for readiness checks, bounded finite
|
||||
activity, failure processing, and replication timing. It exposes distinct
|
||||
user-initiated and unattended entry points, or a typed request which carries
|
||||
interaction authority. The scheduling functions never call a concrete
|
||||
Replicator's `openReplication()` directly.
|
||||
|
||||
`P2P_AutoStart` remains a separate P2P room policy. It is not central
|
||||
Continuous replication and is not `syncOnStart`; its service lifecycle is
|
||||
specified in Part 2. Reopening after `EVENT_DATABASE_REBUILT` is a
|
||||
flow-authorised continuation requested by the Rebuilder, not evidence that
|
||||
AutoStart is enabled.
|
||||
|
||||
Correctness must not depend on the registration order of equal-priority resume
|
||||
handlers. P2P AutoStart records persistent room demand, while an unattended P2P
|
||||
OneShot records finite room demand. `P2PRoomSessionOwner` serialises both and
|
||||
retains the room while either demand remains. Provider-specific P2P lifecycle
|
||||
wiring and host replication scheduling may therefore run in either order.
|
||||
Focused owner tests cover AutoStart-before-OneShot and OneShot-before-AutoStart;
|
||||
the host feature-binding test must not encode their current registration order
|
||||
as a scheduling prerequisite.
|
||||
|
||||
The CLI daemon owns its initial finite convergence before its mirror scan.
|
||||
Restored settings mark that convergence as satisfied for the current lifecycle
|
||||
generation, so `syncOnStart` does not repeat it. In `--interval` mode, the
|
||||
daemon poller is the sole recurring remote-poll scheduler. In changes-feed mode,
|
||||
the scheduling functions start one configured Continuous session when
|
||||
supported; otherwise they may enable the configured generic periodic timer.
|
||||
Continuous has precedence when both are configured.
|
||||
|
||||
The resume function starts work synchronously far enough to reserve
|
||||
Continuous ownership, then lets the lifecycle handler settle without awaiting
|
||||
network completion. Concurrent resume notifications share one internal
|
||||
operation. Periodic reconciliation therefore observes the reservation before
|
||||
it can enable a competing timer. A failed operation is logged and releases the
|
||||
coalescing slot so a later resume can retry.
|
||||
|
||||
Coalescing applies only within one observed lifecycle generation. If the
|
||||
application suspends and resumes while an earlier operation is still settling,
|
||||
the context retains the newer generation and runs it after the earlier
|
||||
operation releases the slot. A result from the obsolete generation cannot
|
||||
change recurring-work ownership or initiate a OneShot fallback for the newer
|
||||
generation.
|
||||
|
||||
Disabling an interval does not retract a callback which the runtime has already
|
||||
queued. Each Periodic callback therefore rechecks lifecycle eligibility,
|
||||
readiness, suspension, configuration, external-poller ownership, and
|
||||
Continuous ownership immediately before it requests replication.
|
||||
|
||||
### Use a fixed current-provider definition
|
||||
|
||||
Commonlib defines the canonical current remote kinds, provider contract,
|
||||
capability catalogue, support-decision type, and typed provider builder. Each
|
||||
host composition explicitly declares the provider kinds which it includes and
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
- canonical kind and diagnostic name;
|
||||
- active Replicator construction;
|
||||
- a configuration predicate and private configuration identity;
|
||||
- explicit user-initiated and unattended OneShot runners;
|
||||
- readiness requirements, an explicit Continuous support decision, and a
|
||||
transfer-stop runner;
|
||||
- the exhaustive current remote-resource catalogue; and
|
||||
- an optional cohesive central-remote administration runner.
|
||||
|
||||
The host composes CouchDB and Object Storage definitions directly. A module is
|
||||
retained only when it owns separate state or behaviour; a factory-registration-
|
||||
only module instance is not required. The stateful P2P service is composed
|
||||
independently and may be an adjunct beside a different selected main provider.
|
||||
|
||||
The definition table uses `satisfies` against required record keys. Adding a
|
||||
current provider or catalogue entry without a support decision is a compile-time
|
||||
error. A typed builder correlates each `supported` decision with its required
|
||||
role and rejects a role for an absent capability.
|
||||
|
||||
Support has three stable states:
|
||||
|
||||
```typescript
|
||||
type CapabilitySupport =
|
||||
| { readonly kind: "supported" }
|
||||
| { readonly kind: "not-implemented"; readonly reason: CapabilityReason }
|
||||
| { readonly kind: "not-applicable"; readonly reason: CapabilityReason };
|
||||
```
|
||||
|
||||
`not-implemented` means that the provider model exists but the current
|
||||
implementation does not supply it. `not-applicable` means that the model does
|
||||
not exist for that provider, such as central database locking for P2P. Reasons
|
||||
are stable codes, not arbitrary user-facing strings.
|
||||
|
||||
Historical empty `remoteType` is resolved explicitly as CouchDB at the
|
||||
persistence/profile boundary. Capability selection then uses that canonical
|
||||
kind; it never infers CouchDB by truthiness or by a negative test such as
|
||||
'neither Object Storage nor P2P'.
|
||||
|
||||
The private configuration identity covers every effective setting which binds
|
||||
the active adapter. The active publication is retained only while both the
|
||||
provider and identity are unchanged. Every changed identity follows the same
|
||||
serialised replacement transition; there is no same-instance rebind branch.
|
||||
|
||||
### Keep the active contract small and compose the differing roles
|
||||
|
||||
The active object implements only the lifecycle and transport primitives which
|
||||
are real for CouchDB, Object Storage Journal, and P2P:
|
||||
|
||||
```typescript
|
||||
interface ReplicatorInstance {
|
||||
initializeDatabaseForReplication(): Promise<boolean>;
|
||||
openReplication(
|
||||
setting: RemoteDBSettings,
|
||||
keepAlive: boolean,
|
||||
showResult: boolean,
|
||||
ignoreCleanLock: boolean
|
||||
): Promise<void | boolean>;
|
||||
terminateSync(): void | Promise<void>;
|
||||
closeReplication(): void | Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
Provider runners compose the differences in interaction authority, readiness,
|
||||
typed settlement, explicit Continuous support or inapplicability, and P2P room
|
||||
demand. Short-lived connection, preferred-tweak, Security Seed, and
|
||||
synchronisation-information operations remain caller-owned resources. Central
|
||||
administration remains one optional cohesive runner. None of those differences
|
||||
widens `ReplicatorInstance`.
|
||||
|
||||
The LiveSync central-remote administration composition shares only local-identity
|
||||
preparation, mutation ordering, milestone interpretation, and result
|
||||
settlement. Its CouchDB and Object Storage adapters retain their own milestone
|
||||
readers and connection or client ownership. The fixed provider definition has
|
||||
already selected the adapter, so those readers validate only the additional
|
||||
operations which they use; they do not rediscover capability support through a
|
||||
concrete-class `instanceof` test.
|
||||
|
||||
The central OneShot adapters likewise require only the local structural
|
||||
`openOneShotReplicationWithOutcome()` operation. Concrete constructors remain
|
||||
at the host-composition boundary, but constructor identity is not a capability
|
||||
test. A structurally incomplete active instance settles as a failed outcome
|
||||
rather than falling back to the legacy `openReplication()` operation.
|
||||
|
||||
Directional Fetch and Rebuild, Streaming Fetch, CouchDB on-demand Chunk reads,
|
||||
remote-size inspection, compromised-Chunk inspection, Garbage Collection,
|
||||
compaction, and journal checkpoint maintenance are workflow or
|
||||
provider-specific concerns. They do not become exhaustive provider
|
||||
capabilities merely because an application flow branches by topology.
|
||||
|
||||
Local node identity initialisation remains at the established Replicator and
|
||||
local-database initialisation boundary for this change. A later physical
|
||||
database-lifetime review may move it only after establishing a concrete owner
|
||||
and migration benefit. Replication statistics remain a `ReplicatorService`
|
||||
telemetry sink.
|
||||
|
||||
### Make unattended work explicit and truthful
|
||||
|
||||
User-initiated and unattended OneShot Sync are separate roles. Interaction
|
||||
authority is an upper bound on local interaction, not an instruction to display
|
||||
a dialogue. An operation may apply a stricter veto, but cannot request an
|
||||
interaction which its caller did not permit. Remote refusal and incoming-peer
|
||||
consent remain independent decisions.
|
||||
|
||||
```typescript
|
||||
type UnattendedTrigger = "resume" | "periodic" | "database-event" | "editor-save" | "file-open" | "merge" | "daemon";
|
||||
|
||||
interface InteractionPermissions {
|
||||
readonly peerSelection: boolean;
|
||||
readonly localPeerAdmission: boolean;
|
||||
readonly configurationExchange: boolean;
|
||||
readonly failureRecovery: boolean;
|
||||
}
|
||||
|
||||
type PermittedInteractionPermissions =
|
||||
| (InteractionPermissions & { readonly peerSelection: true })
|
||||
| (InteractionPermissions & { readonly localPeerAdmission: true })
|
||||
| (InteractionPermissions & { readonly configurationExchange: true })
|
||||
| (InteractionPermissions & { readonly failureRecovery: true });
|
||||
|
||||
type InteractionAuthority =
|
||||
| typeof NO_INTERACTION
|
||||
| { readonly kind: "permitted"; readonly permissions: PermittedInteractionPermissions };
|
||||
|
||||
const NO_INTERACTION = { kind: "forbidden" } as const;
|
||||
|
||||
const REPLICATION_PROGRESS_PRESENTATIONS = {
|
||||
QUIET: "quiet",
|
||||
NOTICE: "notice",
|
||||
} as const;
|
||||
|
||||
type ReplicationProgressPresentation =
|
||||
(typeof REPLICATION_PROGRESS_PRESENTATIONS)[keyof typeof REPLICATION_PROGRESS_PRESENTATIONS];
|
||||
|
||||
interface UserInitiatedOneShotRequest {
|
||||
readonly trigger: "manual";
|
||||
readonly progressPresentation: ReplicationProgressPresentation;
|
||||
readonly interaction: InteractionAuthority;
|
||||
}
|
||||
|
||||
interface UserInitiatedOneShot {
|
||||
run(request: UserInitiatedOneShotRequest): Promise<ReplicationOutcome>;
|
||||
}
|
||||
|
||||
interface UnattendedOneShot {
|
||||
run(request: {
|
||||
readonly trigger: UnattendedTrigger;
|
||||
readonly interaction: typeof NO_INTERACTION;
|
||||
}): Promise<ReplicationOutcome>;
|
||||
}
|
||||
```
|
||||
|
||||
`NO_INTERACTION` is the only all-false authority. Shared immutable authority
|
||||
values are reused by hosts, avoiding a permission allocation per operation.
|
||||
Operation-specific requests, including P2P configuration exchange, carry the
|
||||
same upper bound and expose only relevant permissions. An unattended path may
|
||||
use persisted or automatic acceptance policy, but cannot obtain local
|
||||
interaction authority implicitly.
|
||||
|
||||
Progress presentation and interaction authority are independent parts of the
|
||||
request. `quiet` suppresses routine Notice-level progress, but does not remove
|
||||
authorised peer selection or failure-recovery dialogue. `notice` permits
|
||||
routine progress, but cannot grant an interaction which the request authority
|
||||
forbids. Unattended work always uses `quiet` with `NO_INTERACTION`; it may retain
|
||||
an informational diagnostic, but no-target, authentication, tweak-mismatch,
|
||||
and overlapping-transfer settlements must not promote themselves to a Notice.
|
||||
This remains a request-local policy, not a new process-wide presentation
|
||||
framework.
|
||||
|
||||
The ordinary typed OneShot entry points deliberately retain these established
|
||||
interaction shapes:
|
||||
|
||||
| Entry point | Routine progress | Interaction authority | Request received while another OneShot attempt is active |
|
||||
| --- | --- | --- | --- |
|
||||
| Command-palette `Sync now` | Quiet | Peer selection and failure recovery remain permitted | Settles as `blocked: replication-in-progress` |
|
||||
| Ribbon synchronisation action | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
|
||||
| Config Sync `Sync once` | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
|
||||
| CLI `sync` | Notice | Full user-initiated authority, subject to the host's available interaction mechanisms | Settles as `blocked: replication-in-progress` |
|
||||
| Resume, periodic, database-event, editor-save, file-open, merge, and daemon triggers | Quiet | `NO_INTERACTION` | Settles as `blocked: replication-in-progress` |
|
||||
|
||||
The OneShot owner is acquired before readiness evaluation and retained through
|
||||
provider acquisition, finite activity, and failure handling. A later request
|
||||
is not queued, joined to the running result, or treated as a failed transfer;
|
||||
no work starts for that request. Directional Fetch and Rebuild, P2P-specific
|
||||
commands, and other maintenance workflows remain outside this ordinary
|
||||
OneShot gate and retain their own activity ownership.
|
||||
|
||||
Outcomes do not collapse failure into `void` or `boolean`:
|
||||
|
||||
```typescript
|
||||
const REPLICATION_COMPLETED = { status: "completed" } as const;
|
||||
const REPLICATION_CANCELLED = { status: "cancelled" } as const;
|
||||
|
||||
type ReplicationOutcome =
|
||||
| typeof REPLICATION_COMPLETED
|
||||
| typeof REPLICATION_CANCELLED
|
||||
| { readonly status: "blocked"; readonly reason: ReplicationBlockReason }
|
||||
| { readonly status: "partial"; readonly detail: PartialReplicationDetail }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly error: unknown;
|
||||
readonly recoveryHint?: CentralCompatibilityRecoveryHint;
|
||||
};
|
||||
```
|
||||
|
||||
Completed and cancelled values are shared singletons or literals. Blocked,
|
||||
partial, and failed results may carry diagnostic detail. Central provider
|
||||
initialisation, reset, lock, unlock, and resolution settle only after their
|
||||
defined remote write succeeds; a Rebuilder must not continue after an ignored
|
||||
mutation failure.
|
||||
|
||||
CouchDB and Object Storage Journal record one immutable central-compatibility
|
||||
decision inside the finite attempt which owns the connection or borrowed
|
||||
client. Only a rejection from that exact attempt becomes a recovery hint. A
|
||||
transport failure before assessment, or after an accepted assessment, cannot
|
||||
reuse mutable mismatch or lock state from an earlier attempt. P2P produces no
|
||||
central-compatibility decision. The recovery field is already specific to this
|
||||
contract, so its value carries the stable rejection reason and any preferred
|
||||
tweak value without a redundant kind discriminator.
|
||||
|
||||
`cancelled` means that the requested finite operation did not reach its normal
|
||||
completion boundary. It does not promise rollback. A provider may retain
|
||||
documents and checkpoints from batches which had already settled before the
|
||||
cancellation signal was observed, and a later operation resumes from that
|
||||
durable state.
|
||||
|
||||
### Preserve uncertainty at the boundary which owns the decision
|
||||
|
||||
Capability availability and operation results are separate. A supported
|
||||
operation may fail, while an inapplicable operation must not be called or
|
||||
reported as a network attempt. A workflow which legitimately has no step for
|
||||
its topology may complete that branch without claiming that a provider ran an
|
||||
operation.
|
||||
|
||||
Keep uncertainty where it changes a safety decision. In particular, an Object
|
||||
Storage read distinguishes `available`, `not-found`, and `unavailable` before a
|
||||
caller decides whether creation is permitted. Only explicit `not-found` may
|
||||
create Journal synchronisation parameters; an unavailable read cannot be
|
||||
converted to a missing value or a new Security Seed.
|
||||
|
||||
The same rule applies to central milestone mutation and verification. A
|
||||
milestone mutation may merge an available document or initialise one after an
|
||||
explicit `not-found` result. An unavailable read rejects before upload. A
|
||||
postcondition reader reports that unavailability as a read failure with its
|
||||
diagnostic detail; it does not report that the milestone is missing.
|
||||
|
||||
This principle does not require one generic `RemoteObservation<T>` type or an
|
||||
active-read catalogue. Existing provider-specific inspection and maintenance
|
||||
methods may retain their current compatibility surface until their real
|
||||
consumers are migrated. When a later bounded migration needs to distinguish an
|
||||
observed zero or empty result from an unavailable inspection, that consumer
|
||||
owns the smallest explicit result type required by the decision.
|
||||
|
||||
### Give active ownership and probes explicit boundaries
|
||||
|
||||
`ReplicatorService` is the sole owner of the active Replicator. Replacement and
|
||||
disposal use one explicit quiescing transition under its transition lock:
|
||||
|
||||
```text
|
||||
active -> quiescing -> closed -> replacement published
|
||||
```
|
||||
|
||||
The transition removes the old publication from `current`, rejects later
|
||||
admission, requests the provider's supported transfer cancellation, and drains
|
||||
work which was already admitted. Only then does it close the old Replicator and
|
||||
publish another active context. Acquisitions ordered after the
|
||||
transition receive only the replacement. A cancellation failure does not
|
||||
permit the physical close boundary to be skipped. If admitted work cannot
|
||||
settle, no replacement is published. If physical close fails, the quiescing
|
||||
publication remains fenced so a later transition can retry that close before
|
||||
constructing a replacement.
|
||||
|
||||
The publication object itself is the private generation identity; no separate
|
||||
generation number or public lease is required. Its reservation count is not the
|
||||
service-wide bounded-activity count or finite-replication count: those counts
|
||||
remain status and quiescence signals and can include trials, local work, and an
|
||||
outer Rebuilder flow which itself initiates a lifecycle transition. A switch
|
||||
which waited for either global count could therefore wait for the operation
|
||||
which is awaiting that same switch.
|
||||
|
||||
The active context atomically carries the provider, Replicator, and private
|
||||
configuration identity. A settings-bearing operation captures one effective
|
||||
settings snapshot, then reprojects and compares its identity inside admission
|
||||
immediately before provider dispatch. A mismatch settles without combining a
|
||||
new setting with an earlier Replicator. An explicit stop request acts on the
|
||||
exact active owner and therefore does not require a settings comparison.
|
||||
|
||||
New typed production work cannot synchronously inspect an unreserved active
|
||||
context. It must acquire the context or run inside the admitted callback
|
||||
boundary. The synchronous `inspectActiveReplicatorContext()` view is protected
|
||||
and exists only for lifecycle diagnostics and focused tests.
|
||||
|
||||
The public `getActiveReplicator()` remains temporarily for named compatibility
|
||||
consumers and retains its established missing-active diagnostic. It is not a
|
||||
typed ownership path. A separate side-effect-free `hasActiveReplicator()`
|
||||
predicate may distinguish a compatibility Replicator whose provider was not
|
||||
composed from complete absence. It returns neither the Replicator nor its
|
||||
context, and cannot be used to dispatch work.
|
||||
|
||||
The minimum consumer surface is a callback boundary,
|
||||
`runWithActiveReplicatorContext(callback)`, rather than an exposed lease or
|
||||
release token. Admission is ordered with lifecycle transitions, the callback
|
||||
receives one exact context, and private release runs in `finally` without
|
||||
entering the transition queue.
|
||||
|
||||
The callback must not initiate or await settings realisation, database reset or
|
||||
replacement, active Replicator retirement, or another operation which queues
|
||||
the same lifecycle transition. Such recovery or reconfiguration is staged
|
||||
after the reserved dispatch settles. A process-wide re-entrancy flag would
|
||||
both reject unrelated asynchronous work and miss re-entry after an `await`, so
|
||||
the contract is documented and tested at the owning workflows rather than
|
||||
claimed as a reliable runtime detector.
|
||||
|
||||
Failure presentation and recovery start only after the finite reservation has
|
||||
settled. A later remote mutation re-enters through the callback boundary and
|
||||
requires reference equality with the failed context. It cannot apply the
|
||||
decision produced by one publication to its replacement.
|
||||
|
||||
An edited-settings trial is different: it owns an independent Replicator and
|
||||
connection, never borrows the active publication, and disposes both resources.
|
||||
An owned Security Seed resource also forces a fresh provider read for its
|
||||
settings snapshot. Reusing a process-cached synchronisation parameter would
|
||||
turn an observation made for an earlier flow into current trial evidence.
|
||||
|
||||
Application suspension is a reversible host pause, not an ownership
|
||||
transition. `ReplicatorService` orders the provider's transfer-stop request but
|
||||
retains the active publication, accepts later work, and does not drain or close
|
||||
the Replicator. Provider-specific transport lifecycle, including P2P room and
|
||||
relay handling, remains independently owned.
|
||||
|
||||
Plug-in unload is terminal and reuses the same quiescing retirement as disposal;
|
||||
it does not add another public state or unload capability. The lifecycle handler
|
||||
fences admission, requests transfer cancellation, drains admitted work, and
|
||||
closes the Replicator before `ControlService` closes the local database. This
|
||||
ordering matters even when disabling the plug-in leaves the JavaScript process
|
||||
alive.
|
||||
|
||||
The generic stop role is an idempotent request to stop a provider transfer after
|
||||
transport work begins. It does not promise cancellation of readiness checks,
|
||||
Security Seed acquisition, or external storage calls which do not consume a
|
||||
cancellation signal. P2P implements this role through its room-session owner:
|
||||
the request aborts the current finite-operation scopes without closing the room
|
||||
or disabling later transfers. Its RPC request, incoming `reqSync`, and
|
||||
replication batch loop consume the same effective signal. An already-started
|
||||
atomic database operation may settle before cancellation completes, but no new
|
||||
batch is started afterwards.
|
||||
|
||||
A Journal connectivity preflight is not itself cancelled by this role. The
|
||||
Replicator instead records a private Stop generation when that preflight begins
|
||||
and checks it again before entering `sync()`, `sendLocalJournal()`, or
|
||||
`receiveRemoteJournal()`. A Stop admitted while the preflight is pending can
|
||||
therefore wait for the attempt to settle without allowing a new client transfer
|
||||
to start afterwards.
|
||||
|
||||
The bounded Continuous startup call and an explicit stop request are admitted
|
||||
against their exact publication. Continuous admission ends when the provider
|
||||
has registered ownership and settled startup; it is never retained for the
|
||||
lifetime of the long-lived task. Directional transfer and central-remote
|
||||
administration stop the admitted publication's active transfer before their
|
||||
exclusive operation begins. An unavailable or failed stop prevents that
|
||||
operation rather than allowing transfer and mutation to overlap.
|
||||
|
||||
`getNewReplicator()` is not a general temporary-instance API. CouchDB and
|
||||
Object Storage Setup and settings flows request narrow connection or
|
||||
preferred-tweak probes. A resource-returning factory returns an owned resource
|
||||
with idempotent asynchronous `dispose()`. Trial settings are passed to the
|
||||
probe itself and cannot silently read active settings. P2P Setup instead uses
|
||||
the stable service's connection-probe admission described in Part 2. Only its
|
||||
idle continuation constructs and disposes a short-lived raw signalling trial.
|
||||
Neither form can replace the active Replicator or the P2P service.
|
||||
|
||||
The CouchDB synchronisation-information resource resolves `false` only when it
|
||||
observes incompatible synchronisation information. Connection, setup, and
|
||||
verification failures reject so a settings caller can report operational
|
||||
failure separately from incompatibility. Connection-probe result presentation
|
||||
is likewise explicit: `showResult` retains the established CouchDB success or
|
||||
failure Notice, while an ordinary silent probe emits neither result Notice.
|
||||
|
||||
Streaming Fetch receives an owned Security Seed resource bound to its settings
|
||||
snapshot. The current compatibility implementation may construct an
|
||||
unpublished Replicator internally, but the resource owns and disposes it and
|
||||
cannot replace the active publication.
|
||||
|
||||
### Keep initialisation workflows explicit
|
||||
|
||||
- Fetch, Rebuild, overwrite, and first-device setup remain application
|
||||
workflows. Their direction, reset, lock, peer selection, local-database work,
|
||||
and convergence passes are not one Replicator capability.
|
||||
- CouchDB and Object Storage use their established workflow-local directional
|
||||
adapters and central administration where required.
|
||||
- A P2P first device prepares only its local state. An additional P2P device
|
||||
selects a peer and uses the real finite download path; P2P does not emulate a
|
||||
central reset, lock, milestone, or upload.
|
||||
- CouchDB Streaming Fetch remains a separate initial-transfer service and uses
|
||||
only its owned Security Seed dependency.
|
||||
|
||||
The `EVENT_DATABASE_REBUILT` continuation remains separately authorised and
|
||||
does not imply `syncOnStart` or P2P AutoStart. Complete Rebuilder and
|
||||
maintenance-facade migration is a later bounded change rather than a condition
|
||||
for the active Replicator core.
|
||||
|
||||
## Target capability matrix for current providers
|
||||
|
||||
`S` means supported, `NI` means not implemented, and `NA` means not applicable.
|
||||
Configuration and reachability are request preconditions or outcomes, not
|
||||
support states.
|
||||
|
||||
| Active provider role | CouchDB | Object Storage | P2P |
|
||||
| -------------------------------------- | ------- | -------------- | --- |
|
||||
| User-initiated OneShot Sync | S | S | S |
|
||||
| Unattended OneShot Sync without UI | S | S | S |
|
||||
| Ordinary long-lived Continuous session | S | NA | NA |
|
||||
| Request to stop active transfer | S | S | S |
|
||||
|
||||
Every provider definition records the Continuous row explicitly. CouchDB
|
||||
supplies its runner, while Object Storage and P2P declare the role not
|
||||
applicable; omission is not a fourth support state.
|
||||
|
||||
The current definition also declares the finite resources and the one optional
|
||||
central facility which have real consumers:
|
||||
|
||||
| Provider-owned facility | CouchDB | Object Storage | P2P |
|
||||
| --------------------------------------------- | ------- | -------------- | ------ |
|
||||
| Connection probe | S | S | NA |
|
||||
| Preferred-tweak probe | S | S | NA |
|
||||
| Security Seed resource | S | S | NA |
|
||||
| Synchronisation-information resource | S | NA | NA |
|
||||
| Cohesive central-remote administration runner | S | S | absent |
|
||||
|
||||
`remoteResources` is exhaustive over its four stable machine keys, so adding a
|
||||
resource requires an explicit decision from every composed provider. The
|
||||
central-remote administration field is optional because there is no corresponding
|
||||
P2P facility. Actions within that runner are the current central protocol, not
|
||||
an exhaustive capability table imposed on every Replicator.
|
||||
|
||||
The public contract is named `CentralRemoteAdministration*` because every
|
||||
current action, observation, failure, and postcondition belongs to that central
|
||||
milestone protocol. Established CLI command names remain unchanged.
|
||||
|
||||
The P2P Setup signalling check is not the P2P entry in the provider-owned
|
||||
connection-probe row. P2P has no central connection resource; its
|
||||
`P2PConnectionProbeAdmission` is a focused view of the independently composed
|
||||
P2P service. It compares requested relays with the binding held by the existing
|
||||
room-session owner as specified in Part 2; it adds neither a process-global
|
||||
lease nor a second owner.
|
||||
|
||||
The following concerns deliberately stay outside this capability matrix:
|
||||
|
||||
| Concern | Current owner |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Directional Fetch and Rebuild | Application workflow over provider-specific adapters |
|
||||
| Streaming Fetch | CouchDB initial-transfer workflow plus an owned Security Seed resource |
|
||||
| On-demand remote Chunks and compromised-Chunk inspection | CouchDB compatibility or maintenance consumers |
|
||||
| Remote size, Garbage Collection, compaction, and device registry | Provider-specific inspection and maintenance flows |
|
||||
| P2P room, relay, peer selection, admission, watch, and broadcast | Stable P2P service and room-session owner in Part 2 |
|
||||
|
||||
P2P unattended OneShot means a role exists which uses configured target names
|
||||
without opening a dialogue. Peer-room, watch, acceptance, and broadcast roles
|
||||
are provider-specific facets, not the ordinary Continuous role; their trigger
|
||||
matrix and de-duplication rules are owned by Part 2.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
### Move the resume handler and retain a `remoteType` switch
|
||||
|
||||
This would fix issue 1140 narrowly, but would leave future providers and
|
||||
triggers subject to the same omission. It would not stop automatic P2P calls
|
||||
from reaching an interactive method, and capability semantics would remain
|
||||
implicit.
|
||||
|
||||
### Use only optional methods or Boolean support flags
|
||||
|
||||
Optional runtime roles are useful, but they do not force a support decision when
|
||||
a provider or catalogue entry is added. Exhaustive support metadata plus a
|
||||
typed builder keeps runtime interfaces small while requiring the decision at
|
||||
compile time.
|
||||
|
||||
### Use only a provider-discriminated union
|
||||
|
||||
A provider union is appropriate for provider-specific maintenance after
|
||||
explicit narrowing. It cannot model adjunct P2P beside another selected main
|
||||
provider and is not a generic feature test.
|
||||
|
||||
### Tag every value and hot-path return
|
||||
|
||||
Uniform wrappers would add allocation and noise to Chunk, document,
|
||||
changes-feed, and queue paths without improving semantics where an identity is
|
||||
safe. Tags remain for low-frequency observations and outcomes which affect
|
||||
control flow.
|
||||
|
||||
### Retain `getNewReplicator()` as the trial and command factory
|
||||
|
||||
The handler is order-dependent, can suppress construction errors, and can
|
||||
replace a feature-owned P2P transport. Flow-specific probes and the stable P2P
|
||||
service make ownership explicit.
|
||||
|
||||
### Treat neutral compatibility results as supported operations
|
||||
|
||||
Dummy zero counts, empty Security Seeds, false values which collapse an
|
||||
operational failure into incompatibility or absence, and silent mutations lose
|
||||
distinctions required for safety and recovery. A neutral value remains only
|
||||
when every caller proves it to be the operation's identity.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `syncOnStart` becomes an application policy for every unattended finite
|
||||
provider which supports it, rather than a CouchDB module behaviour.
|
||||
- Adding a current provider or capability requires an explicit compile-time
|
||||
support decision.
|
||||
- Unsupported central operations are no longer represented as successful P2P
|
||||
no-ops or transport errors.
|
||||
- Safe identities remain simple, while safety-sensitive observations are
|
||||
explicit.
|
||||
- Setup and trial configuration cannot replace an active or adjunct transport.
|
||||
- Central mutations and integrity checks cannot silently turn failure or
|
||||
unavailability into success.
|
||||
- P2P remains first-class without being mislabelled as central Continuous
|
||||
replication.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not introduce third-party remote-provider registration.
|
||||
- Do not redesign every replication result or user-facing message in one
|
||||
change.
|
||||
- Do not make Streaming Fetch a generic Replicator capability.
|
||||
- Do not make P2P pretend to own a central remote database.
|
||||
- Do not infer support from `remoteType`, constructor identity, a falsy result,
|
||||
or a neutral value.
|
||||
- Do not redefine `syncOnStart` as a once-per-process setting.
|
||||
- Do not change established profile persistence or Setup flag-file restart
|
||||
ordering as part of this capability split.
|
||||
|
||||
## 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)
|
||||
- [Make Onboarding Profile-Aware](2026_07_multiple_remote_onboarding.md)
|
||||
- [P2P Room and Transport Lifecycle](2026_07_p2p_transport_lifecycle.md)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [CouchDB Remote Connection Ownership](2026_08_couchdb_remote_connection_ownership.md)
|
||||
- [Package the Common Library Behind Explicit Host Boundaries](2026_07_common_library_package_boundary.md)
|
||||
- [Self-hosted LiveSync issue 1140](https://github.com/vrtmrz/obsidian-livesync/issues/1140)
|
||||
- [Self-hosted LiveSync issue 1147](https://github.com/vrtmrz/obsidian-livesync/issues/1147)
|
||||
@@ -0,0 +1,409 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 2: P2P Service and Session Lifecycle
|
||||
|
||||
Series navigation: this is Part 2 of 3. Read [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md)
|
||||
first, then continue with [Part 3: migration plan and verification](2026_08_replicator_capabilities_03_migration_plan.md).
|
||||
|
||||
## Status
|
||||
|
||||
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
|
||||
completed implementation and verification order is recorded in Part 3.
|
||||
|
||||
The implemented state is recorded separately in Commonlib's
|
||||
[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,
|
||||
`room.leave()`, Trystero-owned physical peers, and relay reconnection remain in
|
||||
force. This ADR records the product decision; the Commonlib design document
|
||||
records the implemented names and ownership boundaries.
|
||||
|
||||
## Scope and context
|
||||
|
||||
The Commonlib P2P feature currently spans `TrysteroReplicatorP2PServer`,
|
||||
`TrysteroReplicator`, and the LiveSync-specific `LiveSyncTrysteroReplicator`.
|
||||
The current result resolves a replaceable Replicator while the room, signalling,
|
||||
watch, broadcast, diagnostics, platform-event subscriptions, and database
|
||||
feeds have longer-lived relationships. Installing that replaceable object as
|
||||
the active Replicator makes ordinary active-handle disposal capable of closing
|
||||
an adjunct or policy-owned room.
|
||||
|
||||
The same room membership serves several independent behaviours:
|
||||
|
||||
- P2P AutoStart opens the room and signalling service;
|
||||
- AutoSync reacts to an advertised and accepted peer with one finite transfer;
|
||||
- AutoWatch follows later changes broadcast by a selected peer;
|
||||
- AutoBroadcast publishes local database changes;
|
||||
- explicit commands pull, push, or synchronise against a selected peer;
|
||||
- incoming `reqSync` requests pull from an accepted peer; and
|
||||
- diagnostics and platform events observe the transport.
|
||||
|
||||
These behaviours must share one room owner, but they must not share one
|
||||
implicit policy. A setting change may replace the room session while another
|
||||
provider remains the selected main remote. A setup probe must not close that
|
||||
session. A finite operation may need a room even when AutoStart is disabled.
|
||||
|
||||
## Decision
|
||||
|
||||
### One stable P2P service owns each room session
|
||||
|
||||
The host composes one stable P2P service independently of the selected main
|
||||
provider. It may be present as an adjunct beside CouchDB or Object Storage, or
|
||||
its non-owning adapter may serve as the selected main P2P Replicator. The host
|
||||
owns the service lifetime; the service owns all LiveSync-specific room
|
||||
resources.
|
||||
|
||||
A **P2P room session** is one active room membership and every resource whose
|
||||
validity depends on that membership:
|
||||
|
||||
- the Trystero room, RPC actions, and `RpcRoom`;
|
||||
- its internal session epoch, session controller, and finite-operation
|
||||
registry;
|
||||
- advertisement state and temporary peer decisions;
|
||||
- peer-bound RPC clients and remote database proxies;
|
||||
- connection, diagnostic, and platform-event subscriptions; and
|
||||
- RPC publication, the watch set, database and broadcast change feeds, and
|
||||
in-flight finite-transfer de-duplication bound to the local database.
|
||||
|
||||
A **session epoch** is the internal identity and fence of one room-session
|
||||
object. It is not a public capability, a persisted profile identifier, or a
|
||||
synonym for the logical room. Peer callbacks and finite-operation tokens carry
|
||||
that identity and cannot be routed into a replacement session.
|
||||
|
||||
Closing or replacing a session fences its epoch, stops new operations, settles
|
||||
or fails in-flight work, closes RPC and client resources, and leaves the room
|
||||
in the transport-owned order. Persisted peer acceptance decisions survive;
|
||||
temporary decisions, advertisements, clients, listeners, and feeds do not.
|
||||
The underlying WebRTC peer remains under Trystero's shared-peer ownership as
|
||||
specified by the accepted lifecycle record.
|
||||
|
||||
### Expose narrow contract views
|
||||
|
||||
The service does not expose a room session, raw host, or concrete Replicator to
|
||||
ordinary consumers. It supplies these views over the same owner:
|
||||
|
||||
1. `P2PTransportLifecycle` observes room state and accepts explicit,
|
||||
user-owned connect or disconnect requests.
|
||||
2. `P2PConnectionProbeAdmission` arbitrates a complete Setup signalling check
|
||||
against the current relay binding without exposing or replacing the room.
|
||||
3. `P2PPeerDirectory` supplies peer snapshots and peer arrival or departure.
|
||||
4. `P2PPeerAdmission` evaluates incoming peers and administers temporary or
|
||||
persisted acceptance decisions.
|
||||
5. `P2PTargetedTransfer` performs pull, requested push, and bidirectional
|
||||
finite synchronisation against an explicit peer, and executes the persisted
|
||||
configured-target set without interactive peer selection.
|
||||
6. `P2PChangeRelay` administers peer watch and local-change broadcast.
|
||||
7. `P2PConfigurationExchange` performs peer configuration exchange under its
|
||||
declared interaction authority.
|
||||
8. `P2PDiagnostics` supplies status and RTC diagnostics without exposing raw
|
||||
room or peer connections.
|
||||
|
||||
These are stable service-level contract views, not independent wrapper or state
|
||||
owners. One implementation may satisfy several views.
|
||||
Advertisement and admission state remain under one peer-access owner, while
|
||||
pull, push, and bidirectional transfer remain under one transfer owner. A
|
||||
consumer which needs more than one view receives those views explicitly; it
|
||||
does not receive a general P2P context or service locator.
|
||||
|
||||
The views resolve the current published session at invocation. Finite transfer
|
||||
work which carries an old epoch is rejected or cancelled rather than dispatched
|
||||
into a replacement. A configuration or diagnostic request which was already
|
||||
admitted may settle against its originating session, while persisted peer
|
||||
admission decisions intentionally settle independently of room replacement.
|
||||
These operations are outside active-transfer cancellation and cannot publish
|
||||
the former session's peer callbacks or status into its replacement. The epoch
|
||||
remains internal. The active P2P Replicator is a non-owning adapter over the
|
||||
views, and its disposal cannot implicitly leave the service-owned room.
|
||||
|
||||
### Make explicit disconnect a veto, not another policy demand
|
||||
|
||||
`P2PTransportLifecycle` distinguishes user intent from automation:
|
||||
|
||||
- explicit connect resumes relay reconnection and establishes a user-owned
|
||||
room demand;
|
||||
- explicit disconnect is the sole force-close path, retires the current
|
||||
session, invalidates all demands under the retirement contract, pauses relay
|
||||
reconnection, and establishes a service-lifetime veto against AutoStart; and
|
||||
- only a later explicit connect clears that veto.
|
||||
|
||||
Automated policies and finite operations acquire or release only their own
|
||||
demands. They cannot close a room held by another demand and cannot override an
|
||||
explicit disconnect veto. This is a lifecycle veto, not the opposite of
|
||||
`InteractionAuthority`: local interaction authority is the upper bound used by
|
||||
operations, as specified in Part 1. An operation may impose a stricter veto,
|
||||
but an unattended operation cannot gain permission to open a dialogue.
|
||||
|
||||
`EVENT_DATABASE_REBUILT` is a separately authorised continuation of the owning
|
||||
Rebuilder workflow, not an AutoStart demand. After the replacement database is
|
||||
ready, that continuation may request a room independently of the AutoStart
|
||||
veto. It does not clear the veto for later automatic-start events.
|
||||
|
||||
Host lifecycle closure has a second, private, reversible state. The host sets
|
||||
that state before it cancels delayed automation and closes the room. While it
|
||||
is set, settings reconciliation and finite stable-view operations cannot add a
|
||||
room demand. Only explicit connect, database-rebuild continuation, or the
|
||||
AutoStart schedule established by a resumed host lifecycle clears it; merely
|
||||
reconciling saved settings does not.
|
||||
|
||||
This state does not replace or clear the explicit-disconnect veto. Explicit
|
||||
connect clears both states, resumed AutoStart still observes the user's veto,
|
||||
and Rebuild remains the separately authorised continuation described above.
|
||||
The distinction is private service policy, not another public capability or
|
||||
room owner.
|
||||
|
||||
### Separate automation policy from room ownership
|
||||
|
||||
P2P automation is a composed service feature. It owns `P2P_AutoStart`,
|
||||
AutoSync, AutoWatch, and AutoBroadcast policy, including delayed work and
|
||||
automatic-trigger coalescing. It consumes the transport, peer, admission,
|
||||
transfer, and change-relay views but does not own their mutable state.
|
||||
|
||||
`P2PChangeRelay` owns the actual watch set and database changes feed.
|
||||
`P2PTargetedTransfer` owns explicit finite-transfer and configured-target
|
||||
execution. The stable automation coordinator owns baseline de-duplication
|
||||
shared by AutoSync and configured-target requests. Incoming-peer consent is
|
||||
distinct from a local caller's authority to select a peer or open a dialogue.
|
||||
Persisted or automatic acceptance may authorise an unattended path; it cannot
|
||||
create local interaction authority implicitly.
|
||||
|
||||
Every finite operation which needs a room obtains an internal demand from the
|
||||
room-session owner. Once a session admits the operation, that session owns its
|
||||
operation controller and settlement. The operation consumes an effective
|
||||
signal composed from the room session, its operation controller, and any
|
||||
narrower caller or incoming-RPC signal. Neither the adapter nor the UI consumer
|
||||
owns this bookkeeping.
|
||||
|
||||
Acquiring another demand does not open another session. Releasing it never
|
||||
closes a session still required by AutoStart, another finite operation, or
|
||||
another host consumer. A request to stop active transfer aborts the registered
|
||||
finite-operation controllers but does not abort the room-session controller;
|
||||
the room remains usable and later transfers obtain fresh operation controllers.
|
||||
Retiring the room session aborts its session controller, which cancels every
|
||||
remaining child operation. Demand and controller bookkeeping is internal to the
|
||||
service and is not a general consumer contract; it cannot turn a finite transfer
|
||||
into persistent transport policy.
|
||||
|
||||
Cancellation is cooperative and does not roll back durable work. Pull,
|
||||
requested push, and bidirectional synchronisation propagate the effective
|
||||
signal through the initiating RPC, incoming `reqSync`, the reverse database RPC
|
||||
calls, and the replication batch loop. An atomic PouchDB read or write which has
|
||||
already begun may settle. If a batch write has begun, the operation processes
|
||||
its successful writes and records the batch checkpoint only after every
|
||||
required revision has settled successfully. Cancellation before the write does
|
||||
not advance that checkpoint. The operation then reports a cancelled result and
|
||||
does not start another batch. Session retirement awaits that settlement before
|
||||
releasing RPC and room resources.
|
||||
|
||||
### Reconcile session settings atomically
|
||||
|
||||
The effective P2P session binding is derived from the selected profile, the
|
||||
settings which affect transport, the device identity, and the current local
|
||||
database identity. It is not a new persisted profile identifier or a
|
||||
device-local override. Automation and admission policy is reconciled on the
|
||||
current room. The service reconciles the binding independently of the selected
|
||||
main provider, so an adjunct room can be replaced while CouchDB or Object
|
||||
Storage remains active.
|
||||
|
||||
A change to any binding input retires the whole room session and opens a
|
||||
replacement when policy still requires one. A profile-selection or policy-only
|
||||
change which preserves the effective binding keeps the room and reconciles its
|
||||
current policy. A real replacement preserves one atomic listener and policy
|
||||
boundary: advertisements and temporary peer decisions are reacquired, while
|
||||
persisted peer decisions survive. AutoStart reconnects when it remains enabled,
|
||||
host lifecycle closure has been resumed, and no explicit-disconnect veto is
|
||||
active. No old listener, credential, client, or policy demand remains reachable
|
||||
after replacement.
|
||||
|
||||
The candidate captures its settings, device identity, and local database object
|
||||
when it is constructed. The owner re-reads the effective binding after the room
|
||||
has opened and publishes the candidate only when it still matches. A setting or
|
||||
database change during open therefore retires the stale candidate rather than
|
||||
making it current.
|
||||
|
||||
Reconciliation is serialised with room lifecycle operations:
|
||||
|
||||
1. fence new session work;
|
||||
2. abort the old session's finite-operation scopes and await their cooperative
|
||||
settlement;
|
||||
3. close and leave the old room in transport-owned order;
|
||||
4. open and validate the candidate session; and
|
||||
5. publish the replacement only after it has opened successfully.
|
||||
|
||||
The service never exposes a partly initialised candidate or treats a cancellation
|
||||
request as rollback. If bounded settlement or candidate opening fails,
|
||||
reconciliation reports the failure and publishes no mixed old and new session.
|
||||
A candidate-open failure leaves one observable disconnected state with policy
|
||||
demands unsatisfied; it does not revive the fenced session or start an unbounded
|
||||
retry loop. A later lifecycle trigger or explicit connect may retry.
|
||||
|
||||
### Order local database replacement across both owners
|
||||
|
||||
The database lifecycle transition owns ordering above `ReplicatorService` and
|
||||
the P2P service:
|
||||
|
||||
1. fence acquisition of active Replicator and P2P room work;
|
||||
2. retire the active adapter;
|
||||
3. settle and retire P2P database-bound feeds and publication;
|
||||
4. publish the replacement local database identity only after both boundaries
|
||||
have settled;
|
||||
5. rebind or replace the active provider;
|
||||
6. reconcile P2P against the new database identity; and
|
||||
7. let the Rebuilder request its separately authorised reopen.
|
||||
|
||||
Each service serialises its own resources. The database transition owns the
|
||||
cross-service ordering rather than introducing one transport-wide lock. Reset
|
||||
preparation and explicit database close both await service-owned room
|
||||
retirement before database managers are torn down and the old physical handle
|
||||
is destroyed or closed. Explicit close settles every registered cleanup handler
|
||||
sequentially, even when an earlier cleanup fails; its aggregate result is
|
||||
diagnostic rather than a close veto.
|
||||
|
||||
### De-duplicate by logical lifecycle, not by room epoch
|
||||
|
||||
Completed AutoSync baselines are scoped by normalised peer name and application
|
||||
lifecycle generation. In-flight baseline promises are indexed by normalised
|
||||
peer name until they settle. Trigger provenance and session epoch are not part
|
||||
of either lookup, so a transport-only reconnect cannot repeat an in-flight or
|
||||
completed baseline transfer.
|
||||
|
||||
Both the in-flight baseline promise and completed baseline history belong to
|
||||
the stable automation coordinator and survive transport-only or policy-only
|
||||
session replacement. The originating room session still owns cancellation and
|
||||
settlement of the actual transfer. A lifecycle or logical-identity change clears
|
||||
completed history, but does not clear an in-flight promise. A request for the
|
||||
same normalised peer name may therefore share that promise until it settles.
|
||||
Work from an older automation generation may settle, but cannot publish
|
||||
completion into the current generation. Only completed per-peer baselines are
|
||||
recorded; partial requests record completed peers only, while blocked,
|
||||
cancelled, failed, and incomplete peers remain eligible for a later bounded
|
||||
retry.
|
||||
|
||||
The automation owner clears settled records when the logical peer namespace or
|
||||
local database identity changes. It retains them across transport-only and
|
||||
policy-only replacement. Watch may follow a later advertised change, but does
|
||||
not immediately repeat a completed baseline transfer.
|
||||
|
||||
### Define the P2P trigger matrix
|
||||
|
||||
| Trigger | Required preconditions | Effect |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `P2P_AutoStart` | P2P enabled, resumed lifecycle, cleared host closure, and no user disconnect veto | Open room; do not itself transfer files |
|
||||
| `P2P_AutoSyncPeers` | Open room, matching advertisement, and accepted peer policy | Run one bidirectional finite synchronisation |
|
||||
| `P2P_AutoWatchPeers` | Open room, matching accepted advertisement, and remote broadcasting | Pull later announced updates |
|
||||
| `P2P_AutoBroadcast` | Open room and local broadcasting enabled | Announce later local database changes |
|
||||
| `P2P_SyncOnReplication` | Cleared host closure, no disconnect veto, configured names, and advertisements within a bounded wait | Run target-aware unattended OneShot Sync |
|
||||
| Explicit peer command | Supplied peer target and accepted connection | Run user-owned finite synchronisation |
|
||||
| Incoming `reqSync` | Accepted peer and ordinary readiness | Pull from requesting peer |
|
||||
|
||||
`P2P_AutoStart` is a transport policy, not central Continuous replication and
|
||||
not `syncOnStart`. AutoSync, AutoWatch, and accepted incoming requests remain
|
||||
unattended when their persisted policies permit them. A configured-target
|
||||
request waits for advertisement for a bounded period; it does not inspect a
|
||||
possibly stale snapshot immediately after opening. Missing, undiscovered,
|
||||
unaccepted, or partly successful targets are explicit operation results. An
|
||||
unknown peer never opens an acceptance dialogue on an unattended path.
|
||||
An enabled P2P provider with an empty configured target set settles as
|
||||
`blocked/no-targets`; it is not reported as an unconfigured provider.
|
||||
|
||||
Delayed opens belong to the lifecycle generation which scheduled them.
|
||||
Suspension cancels them or makes them harmless, and the callback rechecks
|
||||
current settings and suspension state before opening. Ordinary automation uses
|
||||
the trigger-aware readiness policy rather than bypassing readiness, pending-file
|
||||
settlement, clean-up, or version gates. Fetch and Rebuild retain their
|
||||
separately authorised bypasses.
|
||||
|
||||
### Arbitrate Setup signalling checks through the room owner
|
||||
|
||||
The current Setup check establishes only whether its signalling transport can
|
||||
be opened. It does not validate peer discovery, room credentials against
|
||||
another device, or a TURN or WebRTC data path. The host therefore receives a
|
||||
`P2PConnectionProbeAdmission` view over the existing room-session owner instead
|
||||
of constructing an uncoordinated second transport.
|
||||
|
||||
The admission receives the requested relay settings and a continuation which
|
||||
owns one short-lived raw signalling trial. The room owner serialises the whole
|
||||
decision on its existing lifecycle queue:
|
||||
|
||||
- a serving room whose active relay set covers every requested relay returns
|
||||
`observed-active` without entering the continuation;
|
||||
- a serving room which does not cover the requested relay set returns the
|
||||
stable `active-p2p-relay-binding-conflict` decision code without entering the
|
||||
continuation; and
|
||||
- an idle owner runs the continuation and does not settle admission until the
|
||||
caller has disposed the raw Replicator and its temporary database.
|
||||
|
||||
Relay admission uses the same split-and-trim projection as transport setup,
|
||||
then compares de-duplicated sets. It does not infer URI equivalence which the
|
||||
Trystero relay key does not implement. The continuation must not await another
|
||||
lifecycle transition on the same service while it holds this serialisation
|
||||
boundary.
|
||||
|
||||
This view adds neither a second room owner nor a process-global relay lease.
|
||||
It does not change raw `TrysteroReplicator.dispose()` semantics, pause or close
|
||||
an active relay, or silently retire the active service to make an incompatible
|
||||
trial possible. A future check which genuinely needs peer-, room-, TURN-, or
|
||||
WebRTC-level evidence requires its own bounded contract rather than widening
|
||||
this signalling-only result implicitly.
|
||||
|
||||
### Keep provider composition explicit
|
||||
|
||||
The stable P2P service can be composed even when P2P is not the selected main
|
||||
provider, while the generic provider table in Part 1 can include or omit the
|
||||
P2P provider at compile time. No runtime unknown-provider registry is implied.
|
||||
When P2P is selected as main, its active adapter delegates to this same service
|
||||
and does not publish a second P2P lifecycle owner.
|
||||
|
||||
## Consequences
|
||||
|
||||
- P2P room, signalling-check admission, peer, watch, acceptance, transfer,
|
||||
configuration, and diagnostics have one explicit owner and focused
|
||||
contracts.
|
||||
- Disposing an active adapter cannot close a policy-owned or adjunct room.
|
||||
- Explicit user disconnect has a clear veto boundary and cannot be undone by
|
||||
AutoStart or a finite operation.
|
||||
- Host lifecycle closure cannot be undone by settings reconciliation or a
|
||||
finite operation before an explicit resume boundary.
|
||||
- Finite operations can request a room without changing persistent room policy.
|
||||
- Settings and local database replacement cannot publish mixed-session state.
|
||||
- Reconnects do not repeat completed baseline transfers merely because the room
|
||||
epoch changed.
|
||||
- Setup can observe a compatible active signalling binding or run an idle
|
||||
trial without mutating the active transport; an incompatible active binding
|
||||
is reported explicitly.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not make the P2P service a general service locator.
|
||||
- Do not expose room, raw host, peer connection, or concrete Replicator state
|
||||
to ordinary consumers.
|
||||
- Do not make P2P own a central remote database.
|
||||
- Do not reinterpret P2P AutoStart as `syncOnStart` or central Continuous
|
||||
replication.
|
||||
- Do not replace the accepted Trystero physical-peer and relay ownership
|
||||
decisions.
|
||||
- Do not add unbounded retry loops or pretend that a missing lower-level stop
|
||||
operation is end-to-end cancellation.
|
||||
|
||||
## 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)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
@@ -0,0 +1,729 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# Architectural Decision Record: Replicator Capabilities and Lifecycle Orchestration — Part 3: Migration Plan and Verification
|
||||
|
||||
Series navigation: this is Part 3 of 3. Start with [Part 1: core contract](2026_08_replicator_capabilities_01_core_contract.md),
|
||||
then [Part 2: P2P service and session lifecycle](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md).
|
||||
This part owns implementation sequencing and verification; it does not add
|
||||
another runtime contract.
|
||||
|
||||
## Status
|
||||
|
||||
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
|
||||
|
||||
Each Commonlib change first runs its focused unit and type-contract tests,
|
||||
builds and validates the packed artefact, installs that exact artefact in
|
||||
Self-hosted LiveSync, and runs focused downstream tests before either
|
||||
repository advances. Compatibility methods remain until every production
|
||||
consumer has migrated.
|
||||
|
||||
The smallest vertical contract which fixes issue 1140 takes priority over
|
||||
unrelated probe, Fast Fetch, maintenance, integrity, and facade work. A stage
|
||||
must not claim completion when it only changes a type declaration while a
|
||||
production caller still uses the old ownership or interaction path.
|
||||
|
||||
## Stage 1: reproduce the current boundary failures
|
||||
|
||||
Add the smallest regression which configures an Object Storage active provider,
|
||||
enables `syncOnStart`, invokes the resume lifecycle after readiness, and
|
||||
expects one unattended finite synchronisation. Run it against 1.0.21 and
|
||||
confirm the expected failure: the CouchDB-owned lifecycle handler excludes
|
||||
Object Storage. Cover both an ordinary Object Storage profile and a migrated
|
||||
profile which retains `liveSync: true`; unsupported Continuous must not suppress
|
||||
the supported `syncOnStart` OneShot policy.
|
||||
|
||||
Add passing characterisation tests which inventory automatic P2P periodic,
|
||||
database-save, editor-save, file-open, and merge triggers, together with P2P
|
||||
AutoSync, AutoWatch, incoming-request, and nested finite-activity paths. These
|
||||
tests record current ownership and dialogue behaviour so that later stages do
|
||||
not accidentally remove a valid automatic path.
|
||||
|
||||
## Stage 2: fix issue 1140 through the minimum vertical contract
|
||||
|
||||
Add the fixed provider definitions and the minimum user-initiated, unattended,
|
||||
and Continuous roles to Commonlib. Give `ReplicationService` typed entry points
|
||||
which carry trigger and interaction policy. Immediately before changing
|
||||
Object Storage handling, add a failing test in which a stopped or failed
|
||||
journal transfer must not produce a completed outcome; then propagate the
|
||||
actual journal result.
|
||||
|
||||
Add the LiveSync-owned replication scheduling serviceFeature, remove the resume
|
||||
handler from `ModuleReplicatorCouchDB`, and route CouchDB Continuous and OneShot
|
||||
Sync plus Object Storage `syncOnStart` through `ReplicationService`. Its private
|
||||
context owns scheduling state, module-level functions implement transitions,
|
||||
and the serviceFeature owns lifecycle and settings-handler registration.
|
||||
Migrate every automatic caller to
|
||||
the unattended entry point in this stage, so periodic and event calls cannot
|
||||
fall back to an interactive P2P role. Migrate manual commands to the
|
||||
user-initiated entry point.
|
||||
|
||||
Replace the factory-registration-only responsibilities of
|
||||
`ModuleReplicatorCouchDB` and `ModuleReplicatorMinIO` with composed provider
|
||||
definitions. Retain a module only for separately identified stateful
|
||||
behaviour; do not retain an instance merely to add a construction handler.
|
||||
|
||||
Serialise active initialisation, replacement, and disposal. Publish the active
|
||||
provider and Replicator as one context after initialisation, clear that context
|
||||
before retiring the old adapter, and keep each typed dispatch on one context
|
||||
snapshot. This is the minimum publication fence for this stage. Waiting for
|
||||
in-flight adapter work and making acquisitions wait for replacement settlement
|
||||
remain part of the later active-construction migration.
|
||||
|
||||
The scheduling context coalesces its network work internally, but an
|
||||
`onResumed` handler settles once that work has been scheduled. It does not hold
|
||||
later resume consumers until a OneShot transfer or Continuous start has
|
||||
settled. Pass one private context with narrow replication, settings, lifecycle,
|
||||
timer, and logging collaborators to module-level functions. Return only the
|
||||
daemon-facing control view, pass it to host composition, and inject it into the
|
||||
CLI command context. Do not retain the view as a public `LiveSyncBaseCore`
|
||||
property or retain scheduling state in a core-keyed `WeakMap`.
|
||||
|
||||
At this boundary, existing P2P AutoSync, AutoWatch, and incoming-request
|
||||
entry points receive the same non-interactive readiness and accepted-peer gate.
|
||||
The no-interaction authority reaches counterpart RPC authorisation and
|
||||
broadcast progress notifications. An unknown peer is blocked rather than
|
||||
prompting.
|
||||
|
||||
Add focused Commonlib owner tests which start an unattended finite room demand
|
||||
before AutoStart demand and after AutoStart demand. Both orders retain one room
|
||||
until every remaining demand has settled. The LiveSync feature-binding test
|
||||
must not rely on the current registration order of equal-priority resume
|
||||
handlers.
|
||||
|
||||
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.
|
||||
Replace `ModuleReplicationLifecycle` and the replication-specific
|
||||
`ModulePeriodicProcess` wiring only after equivalent context and feature-
|
||||
binding tests pass. Reuse the existing timer implementation behind a narrow
|
||||
timer port; changing other periodic feature owners is outside this stage.
|
||||
|
||||
## Stage 3: make P2P transport ownership truthful
|
||||
|
||||
Introduce the stable P2P service and its narrow contract views around the
|
||||
existing implementation while preserving transfer semantics and changing the
|
||||
necessary ownership and lifecycle behaviour. Make the active P2P Replicator a
|
||||
non-owning adapter. Give the service exclusive ownership of room sessions,
|
||||
session-epoch fencing, effective session binding, room open/close/replacement,
|
||||
and settings reconciliation. Consolidate overlapping resume and
|
||||
settings-event handlers.
|
||||
|
||||
Migrate Obsidian panes and commands to lifecycle, peer, admission, transfer,
|
||||
change-relay, configuration-exchange, and diagnostic views as required. Migrate
|
||||
CLI and WebPeer away from concrete-class checks and raw host or room access.
|
||||
Preserve RTC diagnostics through `P2PDiagnostics`, rather than retaining
|
||||
`rawHost`. The compatibility facade may delegate during this stage, but new
|
||||
consumers cannot receive it.
|
||||
|
||||
Separate active-adapter release, room-session leave, and the stop request.
|
||||
Implement the lower-level cooperative cancellation path before declaring the
|
||||
P2P stop role supported: caller abort through RPC request cancellation,
|
||||
incoming-handler signal propagation, signal-bound reverse database RPC calls,
|
||||
and safe batch-boundary termination in `replicateShim`. Add room-session and
|
||||
operation controllers beside internal session-demand ownership for finite
|
||||
operations and policy-held AutoStart, without exposing that bookkeeping as a
|
||||
general consumer API.
|
||||
|
||||
Add ownership regressions immediately before implementation:
|
||||
|
||||
- replacing or disposing the active main adapter does not close a policy-owned
|
||||
or adjunct room;
|
||||
- a disposed session fences late callbacks and clients;
|
||||
- a P2P setting change replaces an adjunct room while another provider remains
|
||||
the active main remote;
|
||||
- persisted peer decisions survive replacement, while temporary decisions and
|
||||
advertisements do not;
|
||||
- local database replacement retires database-bound feeds and publication
|
||||
before manager teardown;
|
||||
- explicit database close settles every dependent cleanup owner before closing
|
||||
the physical handle, even when an earlier cleanup reports failure;
|
||||
- repeated replacement does not retain platform-event subscriptions;
|
||||
- an active-transfer stop aborts finite operations without closing the room,
|
||||
while a later operation can use the same room;
|
||||
- room retirement aborts both locally initiated and incoming `reqSync` work,
|
||||
waits for an already-started atomic database operation to settle, and starts
|
||||
no later batch;
|
||||
- RPC cancellation, timeout, peer departure, and room close abort a
|
||||
cancellation-aware handler rather than only discarding its eventual result;
|
||||
- the inbound request context exists before request admission begins, so a
|
||||
cancellation received while admission waits cannot be lost;
|
||||
- cancellation retains already-settled documents and checkpoints and reports
|
||||
`cancelled`, rather than claiming rollback or completion;
|
||||
- a per-document batch-write failure does not advance the replication
|
||||
checkpoint past the failed revision;
|
||||
- explicit disconnect suppresses AutoStart and relay reconnection until
|
||||
explicit connect, while a separately authorised rebuild continuation can
|
||||
reopen the room without clearing that automatic-start veto;
|
||||
- a candidate whose settings, device identity, or database binding changes
|
||||
while it opens is retired instead of published; and
|
||||
- database replacement fences both active-provider and P2P work before
|
||||
publishing the new database identity, while a failed candidate leaves one
|
||||
observable disconnected state without reviving the fenced session.
|
||||
|
||||
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
|
||||
|
||||
Before implementation, add failing regressions for the headless result-loss
|
||||
path, delayed advertisement, an unaccepted peer, overlapping configured-target
|
||||
and AutoSync requests, suspension before delayed open, and a finite-operation
|
||||
demand beside policy-owned AutoStart demand.
|
||||
|
||||
Implement target-aware unattended P2P work without UI. Add bounded
|
||||
advertisement waiting, peer-acceptance outcomes, finite-operation demands beside
|
||||
policy demands, lifecycle-generation cancellation for delayed opens, and
|
||||
de-duplication across AutoSync and configured-target baseline requests. Keep
|
||||
AutoWatch as the relay for later changes rather than treating it as another
|
||||
baseline transfer.
|
||||
Route P2P automation through trigger-aware readiness without central-remote
|
||||
Security Seed preflight. Add the missing finite-activity boundary to direct
|
||||
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. 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
|
||||
[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
|
||||
|
||||
Make active creation a private, exhaustive `ReplicatorService` operation with
|
||||
one configuration identity and replacement policy. Migrate every non-active caller
|
||||
before restricting `getNewReplicator()`: CouchDB connection and passphrase
|
||||
checks, Object Storage connection and preferred-tweak trials, P2P Setup
|
||||
signalling trials, CLI commands, and other host compositions. Prove that every probe leaves
|
||||
the active main Replicator and adjunct P2P transport unchanged.
|
||||
|
||||
Migrate Streaming Fetch to the owned CouchDB initial-transfer dependencies
|
||||
defined in Part 1. Add replacement-fence, late-settlement,
|
||||
configuration-identity, cache-invalidation, and probe-disposal tests before
|
||||
making active construction private.
|
||||
|
||||
### Implementation position after Stage 5
|
||||
|
||||
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. 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
|
||||
administration, P2P Setup, and Streaming Fetch Security Seed access away from
|
||||
active construction and towards owned resources or focused services. Active
|
||||
replacement is serialised, context acquisition waits for a queued replacement,
|
||||
late candidates are fenced, and short-lived resources are disposed.
|
||||
|
||||
Stage 5 made the P2P Setup trial separately owned, but did not yet arbitrate it
|
||||
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 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
|
||||
|
||||
Replace the active dependency on `LiveSyncAbstractReplicator` with the small
|
||||
`ReplicatorInstance` contract. Retain a publication only while its provider and
|
||||
private configuration identity are unchanged. Every changed identity fences
|
||||
new admission, requests supported transfer cancellation, drains admitted work,
|
||||
closes the old instance, and only then constructs and publishes a replacement.
|
||||
|
||||
Reserve the exact publication around typed finite dispatch and the currently
|
||||
required workflow-local directional attempts. Release before recovery or a
|
||||
dialogue. CouchDB and Journal record an immutable compatibility decision inside
|
||||
the attempt which owns the connection or borrowed client, and a later mutation
|
||||
must re-admit that failed context. Retry and Continuous paths reassess on each
|
||||
new CouchDB connection without adding another logical connection.
|
||||
|
||||
Preserve Journal storage read states through `getSyncParameters()`. Only an
|
||||
explicit `not-found` result permits `SyncParamsHandler` to create and upload new
|
||||
synchronisation parameters. An unavailable read becomes a fetch failure and
|
||||
must not regenerate the shared Security Seed. This fixes issue 1147 within the
|
||||
owned-observation boundary rather than adding another Replicator capability.
|
||||
|
||||
### Contracted Stage 6 implementation position
|
||||
|
||||
The retained implementation is deliberately smaller than the earlier complete
|
||||
consumer-migration proposal:
|
||||
|
||||
- `ReplicatorInstance` contains only initialisation, `openReplication`,
|
||||
transfer termination, and close;
|
||||
- provider definitions retain user-initiated and unattended OneShot runners,
|
||||
an explicit Continuous support decision, readiness, transfer stop, the four
|
||||
owned remote resources, and one optional cohesive central-remote administration
|
||||
runner;
|
||||
- every changed configuration identity replaces the active instance; there is
|
||||
no same-instance rebind policy;
|
||||
- the atomic active context retains that private identity, and every
|
||||
settings-bearing dispatch checks its captured settings against the admitted
|
||||
publication immediately before calling provider code;
|
||||
- typed finite dispatch reserves the exact readiness-tested publication and
|
||||
releases it before failure recovery;
|
||||
- the bounded Continuous startup call and explicit stop request re-admit their
|
||||
exact publication, while a registered long-lived task remains outside a
|
||||
lifetime reservation;
|
||||
- directional transfer and central-remote administration stop active transfer
|
||||
work inside the same admission before beginning their exclusive operation;
|
||||
- CouchDB and Journal carry only a rejected attempt-local compatibility
|
||||
decision into the failure outcome, so a transport failure cannot reuse old
|
||||
mutable fields;
|
||||
- mismatch updates, unlock, and cleaned-remote reconciliation re-admit the
|
||||
failed publication before remote mutation;
|
||||
- P2P uses a narrow non-owning active adapter, takes the same typed finite path,
|
||||
supports its real download workflow, and exposes no central facility;
|
||||
- ordinary central preparation and Streaming Fetch use owned Security Seed
|
||||
resources which dispose their unpublished compatibility instances;
|
||||
- CLI synchronisation diagnoses lock and clean rejection from the exact outcome.
|
||||
Established successful output and exit behaviour are preserved. Typed
|
||||
remote-administration verification failures return non-zero by default;
|
||||
`--compat-remote-admin-exit-zero` restores the former zero result only for
|
||||
returned verification failures, while thrown mutation failures remain
|
||||
non-zero; and
|
||||
- Journal unavailable sync-parameter reads cannot enter the create-and-upload
|
||||
branch required only by explicit absence.
|
||||
|
||||
The active path no longer depends on the giant facade. Existing maintenance,
|
||||
remote-size, on-demand Chunk, migration-inspection, and compatibility consumers
|
||||
may still use focused structural checks or the legacy facade. Their complete
|
||||
migration, local-node-identity redesign, generic milestone extraction, and
|
||||
in-process local-database reset redesign are deferred unless a separate bounded
|
||||
change proves that they are required.
|
||||
|
||||
Each production correction has a focused regression. The verification section
|
||||
distinguishes local source and packed-consumer evidence from registry,
|
||||
real-runtime, and release validation.
|
||||
|
||||
### Active-publication quiescing boundary
|
||||
|
||||
Commonlib implements the publication-scoped callback reservation defined in
|
||||
Part 1 for finite provider dispatch, central-remote administration, exact
|
||||
recovery mutation, and the workflow-local directional attempts which use the
|
||||
active instance. Invocation queues its admission decision in the same order as
|
||||
replacement and disposal. The publication object is its private generation
|
||||
identity. Once admitted, release is idempotent, private, and independent of
|
||||
that queue so quiescing cannot prevent the operation which allows its own drain
|
||||
to settle.
|
||||
|
||||
Every switch follows the same order: fence admission, request supported
|
||||
transfer cancellation, drain admitted callbacks, close, then publish another
|
||||
context. An unchanged provider and configuration identity keeps the existing
|
||||
publication.
|
||||
|
||||
The first implementation regressions cover:
|
||||
|
||||
- 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
|
||||
replacement continues;
|
||||
- finite failure recovery starting only after publication release;
|
||||
- directional workflow retry releasing and then re-admitting the same context;
|
||||
and
|
||||
- terminal unload draining admitted work before Replicator and local-database
|
||||
close, while reversible suspension retains the publication.
|
||||
|
||||
Keep readiness, failure presentation, dialogue, independently owned trial
|
||||
resources, P2P room-session demand, and the lifetime of Continuous replication
|
||||
outside this reservation. Re-admit only its bounded startup call and the
|
||||
bounded explicit stop request. The callback TSDoc forbids awaiting a lifecycle
|
||||
transition which would wait for the same admission to settle.
|
||||
|
||||
## Stage 7: perform a bounded structural review
|
||||
|
||||
Confirm that the contracted core is minimum and robust before a commit or
|
||||
release decision. The active path must stay independent of
|
||||
`LiveSyncAbstractReplicator`, but complete migration of every compatibility
|
||||
consumer is separate work. Remove a dummy or abstract requirement only when
|
||||
its current callers have a truthful alternative; do not turn facade retirement
|
||||
into a condition for issue 1140.
|
||||
|
||||
The retained compatibility surface includes the broad
|
||||
`LiveSyncBaseCore.replicator` view and concrete central adapters required by
|
||||
maintenance and migration code. It is explicitly a partial compatibility view,
|
||||
not the active provider contract. P2P's active adapter does not inherit it.
|
||||
|
||||
CouchDB and Object Storage Journal both have a real central milestone document,
|
||||
but that fact alone does not justify a generic store. Extract a focused contract
|
||||
only when a second current caller demonstrates the same ownership and mutation
|
||||
semantics. P2P has no central milestone document and must not receive a stub or
|
||||
synthetic implementation.
|
||||
|
||||
The final structural review retains the following minimum boundaries:
|
||||
|
||||
- central provider definitions remain outside `LiveSyncBaseCore`; its thin
|
||||
registration method remains as the construction-order composition boundary;
|
||||
- P2P registration remains with the feature which owns the stable P2P service;
|
||||
- `ReplicatorService` and `ReplicationService` remain cohesive services. Their
|
||||
complex state and policy already reside in the focused
|
||||
`ReplicatorService.activeReplicatorState`,
|
||||
`ReplicationService.typedReplication`, `ReplicationService.readiness`,
|
||||
`ReplicatorService.remoteResourceResolver`, and
|
||||
`ReplicatorService.centralRemoteAdministration` collaborators, so another
|
||||
split would not improve the current test seams;
|
||||
- every provider explicitly declares Continuous support or inapplicability;
|
||||
- new typed work uses only acquired or admitted active-context access. The
|
||||
public legacy Replicator getter remains temporarily for named compatibility
|
||||
consumers, while one side-effect-free presence predicate classifies a legacy
|
||||
active instance without returning it or emitting its missing-active Notice.
|
||||
A protected synchronous context inspector remains for focused lifecycle
|
||||
tests;
|
||||
- central-compatibility statuses, recorders, and projection helpers remain
|
||||
package-internal. Only stable rejection reason codes and public recovery
|
||||
value types remain package-index exports; and
|
||||
- redundant recovery-kind data, unused active-state identity matching, and the
|
||||
externally visible legacy administration lookup helper are removed.
|
||||
|
||||
LiveSync implements its optional central-remote administration facility through one
|
||||
shared protocol executor and provider-specific milestone readers. Provider
|
||||
composition selects the reader; a reader validates only the structural
|
||||
operations required for its CouchDB connection or Journal client before any
|
||||
remote mutation. It does not impose concrete-class identity on the generic
|
||||
runner contract.
|
||||
|
||||
The unpublished public contract, provider field, coordinator, and service
|
||||
operation use the `CentralRemoteAdministration*` name because their complete
|
||||
protocol is central-milestone-specific. The central OneShot adapters use a
|
||||
separate local structural operation instead of concrete-class identity. Stable
|
||||
capability-kind comparisons use `CAPABILITY_SUPPORT_KINDS`, and unavailable
|
||||
capabilities settle once without a redundant post-narrowing check.
|
||||
|
||||
Provider-specific configuration identities remain explicit projections of the
|
||||
settings which bind each adapter. They are not replaced with a generic identity
|
||||
builder: the current URL normalisation and setting lists are clearer at the
|
||||
provider boundary, and changing the shared header parser is separate work.
|
||||
|
||||
The review records, but does not prejudge, whether maintained Rebuild and Fetch
|
||||
workflows still require an in-process local-database reset. Prefer a Flag File and restart
|
||||
boundary if it can preserve user intent, CLI behaviour, failure recovery, and
|
||||
the maintained test workflows without losing a supported continuation path.
|
||||
Until that evidence exists, retain the current reset contract and its explicit
|
||||
Replicator-retirement ordering rather than assuming that every workflow has
|
||||
already moved to a restart.
|
||||
|
||||
It also records consumers which retain `LiveSyncLocalDB`, its physical PouchDB
|
||||
handle, or its managers. If broad retention still leaks ownership after the
|
||||
consumer migrations, keep the physical handle and teardown authority in
|
||||
`LiveSyncLocalDB`, and expose only the smallest read-only view or
|
||||
generation-bound operation contract required by each consumer. Do not cache a
|
||||
detached handle snapshot across reset. Preserve the current single active
|
||||
database contract, and add another abstraction only where the inventory shows
|
||||
a concrete lifetime or testability benefit. These recorded questions do not
|
||||
widen Stage 6 or make an anticipatory database abstraction part of this change.
|
||||
|
||||
### Contracted Stage 7 review position
|
||||
|
||||
The structural review retains the contracted service boundaries. The active
|
||||
path remains independent of `LiveSyncAbstractReplicator`; the partial
|
||||
compatibility facade, focused maintenance consumers, and in-process database
|
||||
reset remain bounded deferred work. `ReplicatorService` and
|
||||
`ReplicationService` continue to delegate their complex state and sequencing
|
||||
to the focused collaborators listed above, so another physical service split
|
||||
would add indirection without removing a current responsibility or improving a
|
||||
current test seam.
|
||||
|
||||
One concrete compatibility risk remains deferred with that maintenance work.
|
||||
Journal `tryResetRemoteDatabase()` and `tryCreateRemoteDatabase()` synchronously
|
||||
close and replace their lazy client without first awaiting the transfer
|
||||
settlement owned by `terminateSync()`. CouchDB awaits its corresponding close.
|
||||
Maintained reset and rebuild workflows normally stop ordinary synchronisation
|
||||
before destructive remote work, but the Journal compatibility methods neither
|
||||
encode nor independently test that precondition. A later bounded change must
|
||||
first reproduce the race, then choose workflow-owned suspension, admitted
|
||||
maintenance, or same-instance transfer settlement. It must not add a generic
|
||||
provider capability merely to retire the compatibility facade.
|
||||
|
||||
The review did identify five bounded behavioural corrections inside existing
|
||||
owners:
|
||||
|
||||
- Security Seed resources force a fresh provider read for their settings
|
||||
snapshot instead of accepting process-cached synchronisation parameters as
|
||||
current evidence;
|
||||
- local-database close, reset, failed-initialisation rollback, and
|
||||
physical-database close clean-up share and await the existing
|
||||
active-Replicator retirement for one physical database lifetime;
|
||||
- disposing an unused Journal resource does not report that replication
|
||||
closed, while a Replicator lifecycle message is emitted only when a real
|
||||
active publication is retired and no longer mislabels unload as database
|
||||
reset;
|
||||
- unattended P2P no-target, authentication, tweak-mismatch, and
|
||||
overlapping-transfer settlements retain informational diagnostics without
|
||||
creating Notice-level presentation; and
|
||||
- P2P Setup receives the stable service's `P2PConnectionProbeAdmission` view.
|
||||
Compatible active relay bindings are observed, an active binding which does
|
||||
not cover the requested relay set is blocked with a stable decision code,
|
||||
and only an idle owner runs and awaits the caller-owned raw trial.
|
||||
|
||||
The last correction uses `P2PRoomSessionOwner`'s existing lifecycle queue and
|
||||
adds no room owner, global relay lease, reference count, or raw transport
|
||||
disposal policy. Every maintained production opening of the P2P Setup dialogue
|
||||
passes through one host-owned `SetupManager` seam, which injects the admission
|
||||
view explicitly. The decision code remains separate from the LiveSync-owned
|
||||
English presentation message.
|
||||
|
||||
These corrections close ownership and presentation gaps found by the review;
|
||||
they do not add another generic capability, remote resource, probe framework,
|
||||
or provider role. The target matrices in Part 1 therefore remain unchanged.
|
||||
|
||||
A subsequent falsification and quality pass found further bounded corrections
|
||||
inside the same owners:
|
||||
|
||||
- directional failures retain the immutable compatibility hint from their
|
||||
exact admitted attempt, and recovery re-admits that failed context;
|
||||
- settings-bearing dispatches correlate their snapshot with the active
|
||||
configuration identity, while bounded Continuous startup and explicit stop
|
||||
re-admit the exact publication;
|
||||
- directional transfer and central-remote administration stop active transfer
|
||||
work before their exclusive operation;
|
||||
- readiness calls the application lifecycle method, rather than testing the
|
||||
method object;
|
||||
- cancelled or incomplete P2P pull and push outcomes are not reported as
|
||||
success by the CLI or Obsidian UI, and ordinary UI transfer uses the stable
|
||||
targeted-transfer view rather than the compatibility Replicator. The
|
||||
retained compatibility entry opens that ordinary UI only; it does not
|
||||
perform the transfer;
|
||||
- Journal stop does not construct an unused client, and waits for the transfer
|
||||
promises admitted at its stop boundary, including synchronous setup re-entry;
|
||||
and
|
||||
- remote-size inspection uses one settings snapshot and preserves an observed
|
||||
zero rather than treating it as absence.
|
||||
|
||||
These are lifecycle, settlement, and compatibility-consumer corrections. They
|
||||
do not expand the capability or facility tables.
|
||||
|
||||
A later contract reconciliation found four more bounded truthfulness gaps
|
||||
inside the same existing owners:
|
||||
|
||||
- Object Storage central milestone mutation and postcondition verification
|
||||
preserve `available`, `not-found`, and `unavailable`; only explicit absence
|
||||
can initialise a document, and unavailability cannot upload or become a
|
||||
missing-milestone result;
|
||||
- an enabled P2P provider with no configured targets preserves the Replicator's
|
||||
`blocked/no-targets` result instead of reporting provider absence;
|
||||
- typed active acquisition classifies expected absence through a non-owning,
|
||||
side-effect-free presence predicate rather than the Notice-producing legacy
|
||||
getter; and
|
||||
- the CLI prints a stable explanation when central administration is rejected
|
||||
because the active configuration changed before admission.
|
||||
|
||||
These corrections require no new capability, resource family, state machine,
|
||||
or presentation framework.
|
||||
|
||||
A subsequent lifecycle and diagnostic falsification found four further regressions
|
||||
at those established boundaries:
|
||||
|
||||
- CouchDB synchronisation-information inspection now distinguishes an observed
|
||||
incompatibility from connection, setup, or verification failure, so the
|
||||
settings flow retains its separate existing messages;
|
||||
- an explicitly visible CouchDB connection probe retains its established
|
||||
success or failure Notice, while silent probe consumers remain silent;
|
||||
- a private Journal Stop generation prevents a connectivity preflight which
|
||||
crossed a later Stop boundary from starting a client transfer; and
|
||||
- host P2P lifecycle closure establishes a private reversible gate which
|
||||
settings reconciliation and finite views cannot clear. Explicit connect,
|
||||
database-rebuild continuation, and resumed AutoStart scheduling remain the
|
||||
declared reopen boundaries.
|
||||
|
||||
These corrections refine existing resource, transfer-stop, and P2P lifecycle
|
||||
semantics. They add no provider capability, resource kind, public state, or
|
||||
presentation framework, so the Part 1 matrices remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
### Commonlib unit and type-contract tests
|
||||
|
||||
Cover:
|
||||
|
||||
- exhaustive host-composed definitions for CouchDB, Object Storage, and P2P;
|
||||
- the four-method active `ReplicatorInstance` contract and provider-owned
|
||||
runtime roles;
|
||||
- user-initiated, unattended, blocked, partial, cancelled, and failed OneShot
|
||||
outcomes;
|
||||
- truthful Object Storage stop or transfer failure and headless P2P outcomes;
|
||||
- active, quiescing, disposed, and replacement-published states, including
|
||||
rejection of new work during retirement, acquisition waiting, and late
|
||||
candidate settlement;
|
||||
- unchanged-identity retention, changed-identity replacement, idempotent
|
||||
reservation release, settings-bearing dispatch correlation, and close
|
||||
ordering;
|
||||
- probes which cannot replace the active Replicator or P2P service, including
|
||||
P2P Setup observation and blocking without trial construction, and idle
|
||||
admission which awaits complete disposal of the caller-owned trial;
|
||||
- the deliberately narrow active-transfer stop request, including exact
|
||||
admission, bounded Continuous startup, exclusive-operation ordering, and work
|
||||
it does not claim to cancel;
|
||||
- CouchDB compatibility and transfer using the same owned OneShot connection;
|
||||
- Journal compatibility and transfer using one settings-bound borrowed client;
|
||||
- attempt-local accepted, rejected, and not-assessed decisions, including retry
|
||||
and Continuous reassessment on newly opened CouchDB connections;
|
||||
- Journal synchronisation-parameter reads distinguishing explicit absence from
|
||||
unavailability and never writing after the latter;
|
||||
- Journal central milestone mutation rejecting unavailable reads without an
|
||||
upload, and Object Storage postcondition verification preserving the same
|
||||
read failure and diagnostic detail;
|
||||
- Journal stop avoiding lazy resource construction, settling the transfer
|
||||
promises admitted at its stop boundary, and preventing a deferred
|
||||
connectivity preflight from entering a client transfer after Stop;
|
||||
- cohesive central administration and its truthful mutation settlement;
|
||||
- the narrow non-owning P2P active adapter, including download without a
|
||||
synthetic upload or central facility;
|
||||
- local-database retirement sharing the active owner boundary across reset and
|
||||
close paths;
|
||||
- caller-authority preservation for unattended P2P presentation and truthful
|
||||
Journal and active-lifecycle closure diagnostics;
|
||||
- remote-size inspection retaining one settings snapshot and reporting a zero
|
||||
estimate as an observation;
|
||||
- P2P configured-target execution preserving `blocked/no-targets`; and
|
||||
- P2P host lifecycle closure blocking settings reconciliation and finite room
|
||||
demand until explicit connect, rebuild continuation, or resumed AutoStart;
|
||||
- expected typed absence producing no legacy missing-active Notice.
|
||||
|
||||
### Self-hosted LiveSync unit tests
|
||||
|
||||
Cover:
|
||||
|
||||
- Object Storage `syncOnStart` through resume, including a migrated profile
|
||||
which retains `liveSync: true`;
|
||||
- existing CouchDB Continuous and OneShot paths;
|
||||
- same-generation resume coalescing, a fresh attempt after a later lifecycle
|
||||
generation, and rejection of an obsolete generation's OneShot fallback;
|
||||
- a queued Periodic callback rechecking lifecycle and recurring-work ownership
|
||||
after its interval has been disabled;
|
||||
- the daemon's satisfied initial OneShot marker being consumed even when a
|
||||
Continuous start throws, so a later resume may retry normally;
|
||||
- periodic, database-save, editor-save, file-open, merge, and daemon triggers
|
||||
remaining free of dialogues;
|
||||
- manual P2P and configured peer-targeted flows remaining available;
|
||||
- ordinary Obsidian P2P transfer using the stable targeted-transfer view, with
|
||||
cancelled or incomplete pull and push outcomes remaining non-successful;
|
||||
- P2P AutoStart cancellation across suspension, bounded advertisement waiting,
|
||||
accepted peers without unattended dialogues, remote-broadcast prerequisites,
|
||||
session-demand reference counts, and overlapping peer policies;
|
||||
- the focused P2P service views sharing one room owner without exposing a raw
|
||||
host, room, or concrete Replicator, including connection-probe admission;
|
||||
- session replacement fencing callbacks, clients, temporary decisions,
|
||||
advertisements, and database-bound feeds while retaining persisted decisions;
|
||||
- counterpart RPC authorisation and broadcast progress preserving no-dialogue
|
||||
authority;
|
||||
- Setup and settings validation through owned resources or owner-arbitrated
|
||||
P2P admission, including distinct CouchDB incompatibility and operational
|
||||
failure messages, explicit visible probe Notices, and silent ordinary
|
||||
probes;
|
||||
- readiness invoking the application lifecycle predicate;
|
||||
- exact failed-context mismatch, unlock, and cleaned-remote recovery, including
|
||||
rejection after active replacement;
|
||||
- CLI lock diagnostics from the exact finite outcome rather than mutable fields
|
||||
on a later active Replicator;
|
||||
- CLI process exit codes for successful administration, returned verification
|
||||
failure with and without `--compat-remote-admin-exit-zero`, and thrown
|
||||
mutation failure, including a diagnostic for active-configuration mismatch;
|
||||
- first-device and additional-device initialisation for each current provider;
|
||||
- P2P as the main remote and as an adjunct transport; and
|
||||
- CLI, WebApp, and WebPeer composition against the same contracts, including
|
||||
daemon scheduling after settings restoration and P2P AutoStart reconciliation
|
||||
after a settings change.
|
||||
|
||||
### Focused integration and real-Obsidian verification
|
||||
|
||||
Cover an Object Storage change arriving immediately after start-up without
|
||||
waiting for the periodic interval, ordinary CouchDB start-up, and P2P start-up
|
||||
without an unexpected selection dialogue or transport replacement. P2P
|
||||
validation also covers an accepted configured peer advertising after room open,
|
||||
a watched peer whose remote side broadcasts, and suspension before a delayed
|
||||
AutoStart callback. A focused P2P Setup check must also cover an active room
|
||||
with the same relay set, an attempted additional relay which is blocked without
|
||||
closing that room, and an idle trial which releases its short-lived resources.
|
||||
Deterministic verification must also confirm that a temporarily unavailable
|
||||
synchronisation-parameter read does not upload a new Security Seed.
|
||||
|
||||
The verification report must identify which boundary each real-runtime
|
||||
scenario deliberately exercised. A broad passing suite is not direct evidence
|
||||
for Object Storage `syncOnStart`, an injected unavailable read, P2P automatic
|
||||
scheduling, or active-relay Setup arbitration unless that scenario caused the
|
||||
boundary. Deterministic unavailable-read fault injection may remain at the
|
||||
owned contract-test seam when no reliable real-runtime injection exists; the
|
||||
remaining runtime limitation must then be stated rather than represented as a
|
||||
passing end-to-end scenario.
|
||||
|
||||
The maintained Object Storage Setup URI workflow now exercises the migrated
|
||||
start-up combination deliberately. It persists `liveSync: true` and
|
||||
`syncOnStart: true` on the first device, keeps Periodic replication disabled,
|
||||
stops that device before the second device writes the return note, and then
|
||||
restarts it. The workflow waits for that note without requesting manual
|
||||
replication. Object Storage declares Continuous not applicable, so a successful
|
||||
return journey directly exercises the unattended OneShot fallback owned by
|
||||
start-up scheduling. A settings-save reconciliation before the first device
|
||||
stops cannot satisfy the assertion because the return note does not yet exist.
|
||||
|
||||
The maintained real-Obsidian P2P Setup URI workflow directly exercises Setup
|
||||
URI application, initial Fetch, peer approval and actions, explicit disconnect
|
||||
and reconnect, and bidirectional note transfer. It does not deliberately
|
||||
exercise accepted configured-peer advertising, watched-peer broadcast,
|
||||
suspension before a delayed AutoStart callback, or active-room relay
|
||||
arbitration during P2P Setup. Those exact boundaries retain deterministic unit
|
||||
or contract evidence and are not represented as direct real-runtime proof.
|
||||
|
||||
The maintained MinIO harness has no existing seam which can make exactly one
|
||||
synchronisation-parameter or milestone read unavailable. Stopping MinIO or
|
||||
using invalid credentials fails the preceding bucket-availability check,
|
||||
rather than the owned control-document read. The issue 1147 unavailable-read
|
||||
case therefore remains deterministic at the storage adapter, Journal core,
|
||||
and Replicator contract-test seams, where the assertions preserve
|
||||
`unavailable` and forbid creation or upload. It is not represented as a
|
||||
successful real-Obsidian fault-injection scenario.
|
||||
|
||||
No temporary stage is a release candidate. Release readiness requires the
|
||||
target capability matrix, the contracted core and in-scope consumer migration,
|
||||
focused downstream checks with the exact packed Commonlib artefact, and the
|
||||
real-runtime checks appropriate to the changed boundary. Complete legacy-facade
|
||||
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)
|
||||
- [P2P Transport Compatibility Controls](2026_08_p2p_transport_compatibility.md)
|
||||
- [Bounded Remote Activity](2026_07_bounded_remote_activity.md)
|
||||
- [Self-hosted LiveSync issue 1140](https://github.com/vrtmrz/obsidian-livesync/issues/1140)
|
||||
- [Self-hosted LiveSync issue 1147](https://github.com/vrtmrz/obsidian-livesync/issues/1147)
|
||||
+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,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)
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
date: 2026-08-30
|
||||
commonlib-version: "0.1.19"
|
||||
self-hosted-livesync-version: "1.0.21"
|
||||
status: accepted
|
||||
---
|
||||
|
||||
# Service feature and legacy Module boundaries
|
||||
|
||||
## Purpose
|
||||
|
||||
This document guides new Self-hosted LiveSync composition and bounded refactoring of existing application Modules. It supplements Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) with the risks and migration boundaries specific to `AbstractModule` and `AbstractObsidianModule`.
|
||||
|
||||
Existing Modules remain supported application structures. This guidance does not require mechanical conversion of working code. It defines why a new feature should normally use an existing Service handler or a serviceFeature, and when retaining a Module is still appropriate.
|
||||
|
||||
## Default decision
|
||||
|
||||
For new behaviour:
|
||||
|
||||
1. add a handler to an existing Service when that Service already owns the result, priority, and lifecycle;
|
||||
2. use a serviceFeature when the work composes several Services, ServiceModules, lifecycle events, commands, or host effects;
|
||||
3. keep feature-local state in a private context, with functions which receive that context;
|
||||
4. use a ServiceModule only when several consumers need the same long-lived operational capability or resource lifetime; and
|
||||
5. use a focused class when stable identity, polymorphism, serialised ownership, replacement, `abort()`, `close()`, or `dispose()` is part of the contract.
|
||||
|
||||
Do not select `AbstractModule` or `AbstractObsidianModule` merely to obtain convenient access to `LiveSyncBaseCore`, settings, Services, or Obsidian APIs.
|
||||
|
||||
## How the legacy Module layer works
|
||||
|
||||
`LiveSyncBaseCore` currently composes the application in this order:
|
||||
|
||||
1. retain the constructed Service Hub;
|
||||
2. construct the `ServiceModules` record;
|
||||
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.
|
||||
|
||||
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()`.
|
||||
|
||||
This is a compatibility lifecycle. A serviceFeature does not need to wait for Module binding. It can consume the already constructed Services and ServiceModules directly.
|
||||
|
||||
## Why new code should avoid `AbstractModule`
|
||||
|
||||
### Dependencies are broader than the type signature
|
||||
|
||||
An `AbstractModule` constructor receives `LiveSyncBaseCore`. Through that one object, a subclass can reach:
|
||||
|
||||
- the complete Service Hub;
|
||||
- every ServiceModule;
|
||||
- the active local database;
|
||||
- settings and setting persistence;
|
||||
- application commands, views, ribbon icons, and protocol handlers; and
|
||||
- path, readiness, logging, and test helpers.
|
||||
|
||||
A reader cannot determine the real dependency set from the constructor or class declaration. A serviceFeature using `NecessaryServices` makes that set visible and compiler-checked.
|
||||
|
||||
### Initialisation is split across construction and binding
|
||||
|
||||
Module fields can dereference `this.services` during class field initialisation, while public behaviour is registered later in `onBindFunction()`. Correctness consequently depends on both the host construction order and a second binding phase.
|
||||
|
||||
This permits states which are difficult to express in a type:
|
||||
|
||||
- the class exists but its handlers are not registered;
|
||||
- a field has captured a Service before the intended lifecycle point;
|
||||
- a method passed as a callback has lost its receiver; or
|
||||
- a test invokes `onBindFunction()` against a partial object which could not occur through ordinary composition.
|
||||
|
||||
### Callback safety is checked at runtime
|
||||
|
||||
Legacy Modules commonly register `this.method.bind(this)`. `__$checkInstanceBinding()` can report an underscore-prefixed method which is not referenced by `onBindFunction()`, but it does not type-check the registration or prove that a callback retains its receiver. A module-level function receiving an explicit context does not have a receiver to lose.
|
||||
|
||||
### Registry and ordering dependencies remain implicit
|
||||
|
||||
Modules are stored in one runtime list. Construction order, binding order, `getModule()`, and subclass identity can become hidden dependencies. A serviceFeature is called at the composition root and returns only an intentionally retained view, so its consumers do not need a general Module locator.
|
||||
|
||||
### Resource ownership is not part of the base contract
|
||||
|
||||
`AbstractModule` has no standard replacement, cancellation, or disposal contract. Individual Modules can register `onUnload` handlers, but accepting the core does not state which object owns a queue, remote handle, room, timer, or in-flight operation.
|
||||
|
||||
Use a focused owner when the resource lifetime is meaningful, then compose that owner through a serviceFeature. The owner should expose the smallest necessary `abort()`, `close()`, `dispose()`, or view contract.
|
||||
|
||||
### Tests inherit unrelated application structure
|
||||
|
||||
Current Module tests sometimes call a prototype method with manually assembled objects:
|
||||
|
||||
```typescript
|
||||
ModuleReplicator.prototype.onBindFunction.call(module, {} as never, services as never);
|
||||
```
|
||||
|
||||
Other tests construct a broad fake core so that the base class can expose one or two collaborators. These tests can verify behaviour, but the fixture cost obscures the actual interaction contract and makes unrelated Service changes more likely to affect them.
|
||||
|
||||
When a focused London School test requires a broad core fixture, repeated `as never`, deep mock chains, or manual prototype invocation, treat that friction as a design-review signal.
|
||||
|
||||
## Why `AbstractObsidianModule` is a more restrictive boundary
|
||||
|
||||
`AbstractObsidianModule` adds direct access to the plug-in and `app` on top of the complete core. This is useful for existing Obsidian-owned integration, but it combines platform policy, application composition, and domain behaviour in one inheritance boundary.
|
||||
|
||||
For new behaviour, keep Obsidian-specific presentation or registration in an Obsidian-owned serviceFeature. Pass host-neutral operations or focused views into that feature. This permits the CLI, WebApp, WebPeer, and unit tests to reuse the operation without constructing an Obsidian plug-in.
|
||||
|
||||
## Current examples
|
||||
|
||||
### A small serviceFeature: language initialisation
|
||||
|
||||
`src/serviceFeatures/onLayoutReady/enablei18n.ts` declares only `setting`, `API`, and `appLifecycle`:
|
||||
|
||||
```typescript
|
||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => {
|
||||
// Apply the language, persist a change, and register unload clean-up.
|
||||
});
|
||||
```
|
||||
|
||||
The local `ObsidianLanguageAppliedNotice` class is still appropriate. It owns one replaceable Obsidian `Notice` and has an explicit `clear()` lifetime operation. The class is not used as a service locator, and the serviceFeature owns its construction and host binding.
|
||||
|
||||
### Operation and composition: database preparation
|
||||
|
||||
Commonlib's `prepareDatabaseForUse()` is independently callable and receives explicit collaborators. `usePrepareDatabaseForUse()` constructs the error manager and registers the operation with `databaseEvents.initialiseDatabase`.
|
||||
|
||||
This split allows tests to verify:
|
||||
|
||||
- database opening before scanning;
|
||||
- short-circuiting after a failed step;
|
||||
- completion handlers before pending-event commitment;
|
||||
- readiness only after every required step; and
|
||||
- registration of the composed operation.
|
||||
|
||||
The operation does not need an application Module identity.
|
||||
|
||||
### 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
|
||||
|
||||
`ModuleConflictChecker` currently combines:
|
||||
|
||||
- conflict policy decisions;
|
||||
- two `QueueProcessor` owners;
|
||||
- cancellation signalling;
|
||||
- access to settings and active-file state; and
|
||||
- registration into the conflict Service.
|
||||
|
||||
Its queues are class fields which dereference `this.services` during field initialisation, and its public handlers are bound later in `onBindFunction()`.
|
||||
|
||||
A bounded change to this area should prefer a shape such as:
|
||||
|
||||
```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.
|
||||
|
||||
## Interaction-based testing
|
||||
|
||||
Test a serviceFeature at two levels.
|
||||
|
||||
First, test the operation or state owner with narrow collaborators:
|
||||
|
||||
```typescript
|
||||
it("does not enqueue after an optional resolver completes the conflict", async () => {
|
||||
const enqueue = vi.fn();
|
||||
const resolveOptionally = vi.fn(async () => true);
|
||||
|
||||
await queueConflictCheck(contextWith({ enqueue }), dependenciesWith({ resolveOptionally }), path);
|
||||
|
||||
expect(resolveOptionally).toHaveBeenCalledWith(path);
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
Second, test the composition:
|
||||
|
||||
```typescript
|
||||
it("registers conflict checking with the conflict Service", () => {
|
||||
const setHandler = vi.fn();
|
||||
|
||||
useConflictChecking(makeHost({ setHandler }));
|
||||
|
||||
expect(setHandler).toHaveBeenCalledOnce();
|
||||
expect(setHandler).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
```
|
||||
|
||||
The test should make the interaction contract legible: which collaborator is called, in which order, what result is returned, and what must not run after a failure.
|
||||
|
||||
Do not expose a private constructor, publish a broad mock, or attach a context to `LiveSyncBaseCore` solely to make a test possible. If the narrow test cannot be written cleanly, reconsider the responsibility split.
|
||||
|
||||
## When retaining a Module is appropriate
|
||||
|
||||
Retain or extend an existing Module when the current change depends on its established:
|
||||
|
||||
- Module identity or `getModule()` lookup;
|
||||
- binding order with neighbouring legacy Modules;
|
||||
- Obsidian plug-in lifecycle integration;
|
||||
- user interface object lifetime; or
|
||||
- compatibility behaviour whose extraction would materially expand the change.
|
||||
|
||||
Even then, new domain operations can receive explicit dependencies instead of accepting the Module or complete core. Improve the affected ownership boundary without converting unrelated neighbours.
|
||||
|
||||
## Migration approach
|
||||
|
||||
When a Module is already in scope:
|
||||
|
||||
1. name the behaviour being changed and the state or resource which owns it;
|
||||
2. identify the smallest operation which can accept explicit dependencies;
|
||||
3. add a focused regression or interaction test around that operation;
|
||||
4. keep host-specific registration in the Module initially, if that is the smallest safe step;
|
||||
5. move registration to a serviceFeature only when the current integration can do so without changing ordering or lifetime; and
|
||||
6. remove the legacy Module only when no identity, lookup, ordering, or compatibility consumer remains.
|
||||
|
||||
This is an incremental boundary change, not an inheritance-removal campaign.
|
||||
|
||||
## Review checklist
|
||||
|
||||
Before adding or changing application composition, confirm that:
|
||||
|
||||
- dependencies are visible in a function, context, or constructor type;
|
||||
- mutable state has one named owner;
|
||||
- shared state is not promoted to a ServiceModule without multiple consumers;
|
||||
- external resources have explicit replacement and disposal semantics;
|
||||
- host-specific UI remains outside host-neutral operations;
|
||||
- a consumer receives a focused view rather than the complete core;
|
||||
- handler ordering and failure short-circuiting are tested; and
|
||||
- retaining a legacy Module is an explicit compatibility decision.
|
||||
@@ -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.
|
||||
+11
@@ -44,6 +44,17 @@ Both settings contain server addresses, but they are not interchangeable.
|
||||
|
||||
A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service.
|
||||
|
||||
## Connection compatibility profiles
|
||||
|
||||
`P2P Configuration` includes a separate `Connection compatibility` section. Its defaults preserve the existing transport behaviour:
|
||||
|
||||
- **P2P message size** defaults to **Standard**. **Reduced**, **Conservative**, and **Maximum compatibility** progressively limit outgoing P2P messages when a network path appears to drop larger WebRTC messages. This is not a Vault Chunk size or an IP MTU. Smaller values add framing and processing overhead.
|
||||
- **Connection path** defaults to **Automatic**, which lets WebRTC select a viable direct or TURN-relayed path. **TURN relay only** forces the encrypted connection through TURN and is available only when the profile contains at least one valid `turn:` or `turns:` URL.
|
||||
|
||||
The sending device controls its outgoing message size. Select the same conservative preset on every device which may send across the constrained path. Existing devices do not receive the choice retrospectively merely because another device changed it.
|
||||
|
||||
Both compatibility choices belong to the saved P2P profile and are retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID, passphrase, and relay list while selecting different compatibility choices. Only the selected P2P profile joins the group.
|
||||
|
||||
## P2P Status
|
||||
|
||||
The **P2P Status** pane is the current Obsidian interface for P2P connections.
|
||||
|
||||
@@ -2,6 +2,64 @@
|
||||
|
||||
This document contains earlier published releases from the 1.0 line of the [current Self-hosted LiveSync release history](../../updates.md). Beta and release-candidate builds published before 1.0.0 are recorded in the [1.0 preview history](1.0-previews.md). Earlier release lines continue in the [0.25 history](0.25.md) and the [legacy history](legacy.md).
|
||||
|
||||
## 1.0.15
|
||||
|
||||
15th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- Start-up offline scanning is now faster, especially for larger Vaults using path obfuscation (Commonlib 0.1.15).
|
||||
|
||||
### Interface and translation
|
||||
|
||||
#### Improved
|
||||
|
||||
- The Traditional Chinese translation catalogue has been completed and polished for broader coverage and more natural, consistent terminology (PR #1106). Thank you to @nimula for the contribution!
|
||||
|
||||
## 1.0.14
|
||||
|
||||
14th August, 2026
|
||||
|
||||
Thank you for your patience. At last, it looks as though we can clear some of the Community Review warnings.
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- CouchDB operations which run to completion now close their temporary remote database connections after use across both Commonlib and LiveSync, including Setup Wizard and settings probes, command-line milestone verification, database maintenance, Security Seed refreshes, status queries, and retry and error paths (Commonlib PR #112).
|
||||
- This covers every temporary connection currently identified as a possible contributor to the long-running resource growth tracked in #1034. Validation over extended sessions is continuing.
|
||||
- Thank you to @apple-ouyang for the contribution!
|
||||
|
||||
## 1.0.13
|
||||
|
||||
13th August, 2026
|
||||
|
||||
### Conflict handling and recovery
|
||||
|
||||
#### Improved
|
||||
|
||||
- **Inspect conflicts and file/database differences** now reports local Metadata whose stored document ID does not match the ID derived from its recorded path. Ordinary scans leave unresolved entries and their corresponding Vault paths unchanged, while allowing consistently addressed Metadata for the same logical path to proceed normally.
|
||||
- When the current winner has no conflict leaves and has an unambiguous target, its wrench menu can repair that one local Metadata document after separate confirmation. The target is written and verified before the mismatched source ID is removed; ambiguous or otherwise unsafe entries remain read-only.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Fetch now writes deletion tombstones to the local database without attempting to decrypt them. A tombstone has no encrypted payload, and decryption previously aborted the whole fetch at the first deleted document. New devices could not complete their initial sync on vaults that contain old deletions (Commonlib PR #108).
|
||||
- Thank you to @KennethLloyd for the contribution!
|
||||
|
||||
## 1.0.12
|
||||
|
||||
11th August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- One-shot CouchDB replication now closes its temporary remote database after each run and before retrying, preventing inactive PouchDB instances from accumulating during long-running periodic synchronisation (Commonlib PR #75). Thank you to @apple-ouyang for the contribution!
|
||||
- Start-up and recovery scans now keep failed database-to-Vault writes retryable instead of recording them as successful and later mistaking the still-missing file for a local deletion (Commonlib PR #106).
|
||||
- Files over the size limit or in conflict remain deliberately skipped, while actual write failures are reported to Fast Setup, CLI mirror, and daemon callers.
|
||||
|
||||
## 1.0.11
|
||||
|
||||
9th August, 2026
|
||||
|
||||
+79
-17
@@ -4,6 +4,19 @@ NOTE: This document not completed. I'll improve this doc in a while. but your co
|
||||
|
||||
There are many settings in Self-hosted LiveSync. This document describes each setting in detail (not how-to). Configuration and settings are divided into several categories and indicated by icons. The icon is as follows:
|
||||
|
||||
On Obsidian 1.13 or later, the root settings page is organised by task. On an unconfigured installation, **Quick Setup** appears first, followed by **Synchronisation** and **General Settings**. Once this plug-in has been configured, **Synchronisation** and **General Settings** appear first, followed by **Set up other devices** and **Quick Setup**. Earlier supported Obsidian versions retain a pane-based interface with the same controls; they open **Quick Setup** when unconfigured and **General Settings** when configured.
|
||||
|
||||
| Icon | Root group | Contents or availability |
|
||||
| :--: | ------------------------ | ------------------------------------------------------------- |
|
||||
| 🧙♂️ | Quick Setup | Setup URI, onboarding, and enable actions |
|
||||
| 🔄 | Synchronisation | Remote Configuration and Sync Settings |
|
||||
| ⚙️ | General Settings | Appearance, Logging, and Extra menus |
|
||||
| 📲 | Set up other devices | Copy a Setup URI or show its QR code after configuration |
|
||||
| 🛠️ | Maintenance and recovery | Maintenance and Hatch |
|
||||
| 🧩 | Extra features | Selector and Customisation sync when advanced features appear |
|
||||
| 🔧 | Advanced settings | Advanced, Power users, and Patches when their modes appear |
|
||||
| ℹ️ | Help and information | Help and troubleshooting, and Change Log |
|
||||
|
||||
## Feature maturity for 1.0
|
||||
|
||||
The following status applies to optional and compatibility features in the 1.0 line:
|
||||
@@ -15,10 +28,32 @@ The following status applies to optional and compatibility features in the 1.0 l
|
||||
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
|
||||
| Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. |
|
||||
|
||||
### Apply changes which require initialisation
|
||||
|
||||
Some compatibility settings are not saved immediately. They remain pending
|
||||
until **Apply** is selected. The Apply action remains visible on the root
|
||||
settings page and on the relevant child page. The following dialogue asks which
|
||||
existing data should be used after restarting:
|
||||
|
||||
- **Reset Synchronisation on This Device** reconstructs this device's local
|
||||
database from the configured remote. For P2P, an online source device is
|
||||
selected after restart.
|
||||
- **Overwrite Server Data with This Device's Files** reconstructs the local and
|
||||
remote databases from this Vault. The P2P equivalent prepares only this
|
||||
device from its current Vault files.
|
||||
- **Review another way to apply these settings** returns to a separate choice
|
||||
between keeping the changes pending and applying them without initialisation.
|
||||
Applying them alone is an advanced compatibility fallback and can make the
|
||||
device incompatible with its existing synchronisation data.
|
||||
|
||||
LiveSync reserves the selected next-start operation before saving the pending
|
||||
settings. If validation or that reservation fails, the settings remain
|
||||
unapplied and the settings-only fallback is not offered.
|
||||
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 💬 | [0. Change Log](#0-change-log) |
|
||||
| 🧙♂️ | [1. Setup](#1-setup) |
|
||||
| 🧙♂️ | [1. Quick Setup and Extra menus](#1-quick-setup-and-extra-menus) |
|
||||
| ⚙️ | [2. General Settings](#2-general-settings) |
|
||||
| 🛰️ | [3. Remote Configuration](#3-remote-configuration) |
|
||||
| 🔄 | [4. Sync Settings](#4-sync-settings) |
|
||||
@@ -34,17 +69,19 @@ The following status applies to optional and compatibility features in the 1.0 l
|
||||
|
||||
This pane always shows the current release history. It does not track whether a particular plug-in version has been read and does not open automatically after an ordinary update.
|
||||
|
||||
Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. Closing the dialogue keeps synchronisation paused. When the detected state can be handled by the running version, the explicit resume action records the current internal database version and restores the configured behaviour. A persistent Notice and the `Review why synchronisation is paused` command reopen the review. An older installation cannot dismiss a pause caused by a newer database or settings version.
|
||||
Internal database or settings compatibility reviews use a separate safety dialogue, not this pane. After the Obsidian layout is ready, a pending review opens as **Synchronisation paused for compatibility review**. The dialogue explains why remote synchronisation has been paused and preserves the automatic synchronisation choices which were configured before the update. Closing it or selecting **Keep synchronisation paused** leaves synchronisation paused. Use the persistent Notice's **Review why** link, or run the `Review why synchronisation is paused` command, to reopen it. Opening **Change Log** does not acknowledge the review.
|
||||
|
||||
## 1. Setup
|
||||
A configured Vault which was copied, restored, or opened in a new Obsidian profile can require this review because its device-local acknowledgement is not part of the Vault data. An empty local database is not accepted as evidence that it is safe to continue. An existing unconfigured Vault remains in onboarding without this synchronisation warning; its missing acknowledgement is not filled in automatically, so it is evaluated if the Vault is configured later. When the detected state can be handled by the running version, **Resume synchronisation** records the current internal database version and restores the configured behaviour. An older installation cannot dismiss a pause caused by a newer database or settings version.
|
||||
|
||||
This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync.
|
||||
## 1. Quick Setup and Extra menus
|
||||
|
||||
An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**.
|
||||
Quick Setup contains the actions used to configure Self-hosted LiveSync. On Obsidian 1.13 or later these actions appear on the root settings page. In the pane-based interface, they remain available together on the **Quick Setup** pane.
|
||||
|
||||
An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, use **Rerun Onboarding Wizard** in the root **Quick Setup** group on Obsidian 1.13 or later. On earlier supported Obsidian versions, open **Self-hosted LiveSync settings** → **Quick Setup** → **Rerun Onboarding Wizard**.
|
||||
|
||||
Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan.
|
||||
|
||||
### 1. Quick Setup
|
||||
### 1. Setup actions
|
||||
|
||||
Most preferred method to setup Self-hosted LiveSync. You can setup Self-hosted LiveSync with a few clicks.
|
||||
|
||||
@@ -64,22 +101,15 @@ Completing manual CouchDB, Object Storage, or P2P setup creates the correspondin
|
||||
|
||||
This button only appears when the setup was not completed. If you have completed the setup manually, you can enable LiveSync on this device by this button.
|
||||
|
||||
### 2. To setup other devices
|
||||
### 2. Set up other devices
|
||||
|
||||
#### Copy the current settings to a Setup URI
|
||||
|
||||
You can copy the current settings as a new setup URI. And this URI can be used to setup the other devices as [Use the copied setup URI](#use-the-copied-setup-uri).
|
||||
|
||||
### 3. Reset
|
||||
### 3. Extra menus
|
||||
|
||||
#### Discard existing settings and databases
|
||||
|
||||
Reset the Self-hosted LiveSync settings and databases.
|
||||
**Hazardous operation. Please be careful when using this.**
|
||||
|
||||
### 4. Enable extra and advanced features
|
||||
|
||||
To keep the set-up dialogue simple, some panes are hidden in default. You can enable them here.
|
||||
To keep the settings dialogue concise, some menus and features are hidden by default. On Obsidian 1.13 or later, enable them through **General Settings** → **Extra menus**. In the pane-based interface, the same controls appear in General Settings.
|
||||
|
||||
#### Enable advanced features
|
||||
|
||||
@@ -413,6 +443,14 @@ Setting key: P2P_relays
|
||||
|
||||
The Nostr-compatible WebSocket relay URL or URLs used for peer discovery and WebRTC connection negotiation. Multiple URLs can be separated by commas. A signalling relay does not store or transfer Vault contents. See [How peer-to-peer synchronisation works](p2p.md).
|
||||
|
||||
The P2P Setup connection test does not interrupt an active P2P room. When the
|
||||
active relay set already covers the requested URLs, the test observes that
|
||||
active signalling transport. If the test would add a relay while P2P is
|
||||
active, it asks you to use the active relay settings or disconnect P2P first.
|
||||
When P2P is idle, the test opens and disposes a short-lived signalling trial.
|
||||
This check does not prove peer discovery, room credentials against another
|
||||
device, or a TURN or WebRTC data path.
|
||||
|
||||
#### Group ID
|
||||
|
||||
Setting key: P2P_roomID
|
||||
@@ -465,6 +503,22 @@ Setting key: P2P_turnCredential
|
||||
|
||||
The password or credential for authentication with the TURN server.
|
||||
|
||||
#### P2P message size
|
||||
|
||||
Setting key: P2P_maxWirePayloadBytes
|
||||
|
||||
This profile setting limits each outgoing Commonlib RPC message before Trystero applies its own framing. It is not a Vault Chunk size, an IP MTU, or an SCTP fragment size. The available presets are **Standard** (15,360 bytes), **Reduced** (2,048 bytes), **Conservative** (1,024 bytes), and **Maximum compatibility** (800 bytes). Smaller values trade throughput for compatibility on paths which appear to drop larger WebRTC messages.
|
||||
|
||||
The sender controls the size of its outgoing messages. Select the same conservative preset on every device which may send across the constrained path. Existing profiles without this key use **Standard**. P2P connection strings and encrypted Setup URIs retain the selected preset.
|
||||
|
||||
#### Connection path
|
||||
|
||||
Setting key: P2P_connectionPath
|
||||
|
||||
**Automatic** lets WebRTC select a viable direct or TURN-relayed path and is the default. **TURN relay only** forces `iceTransportPolicy: 'relay'` and is available only when the profile contains at least one valid `turn:` or `turns:` URL. Removing the last valid TURN URL while relay-only mode is selected restores **Automatic** and displays a Notice.
|
||||
|
||||
This choice belongs to the P2P profile and is retained in P2P connection strings and encrypted Setup URIs. Separate profiles may use the same Group ID and credentials with different compatibility choices; only the selected P2P profile is active.
|
||||
|
||||
## 4. Sync Settings
|
||||
|
||||
### 1. Synchronisation Preset
|
||||
@@ -1059,7 +1113,11 @@ Purge all download/upload cache.
|
||||
|
||||
#### Fresh Start Wipe
|
||||
|
||||
Delete all data on the remote server.
|
||||
Delete all data on the remote server in batches; this operation is not
|
||||
transactional. Stop all synchronising devices before starting. If the
|
||||
operation is interrupted or reports failure, keep them stopped, rerun Fresh
|
||||
Start Wipe, and then use **Overwrite Server Data with This Device's Files**
|
||||
from an authoritative Vault.
|
||||
|
||||
### 6. Garbage Collection V3 (CouchDB only)
|
||||
|
||||
@@ -1069,4 +1127,8 @@ Use it only when the Vault, local database, and remote are healthy, and every re
|
||||
|
||||
### 7. Reset
|
||||
|
||||
#### Discard existing settings and databases
|
||||
|
||||
Reset the Self-hosted LiveSync settings and local database. This is a hazardous operation; make a backup before using it.
|
||||
|
||||
#### Delete local database to reset or uninstall Self-hosted LiveSync
|
||||
|
||||
@@ -20,7 +20,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
|
||||
|
||||
### Independent revision properties
|
||||
|
||||
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
|
||||
The modifiers defined under [Revision](glossary.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
|
||||
|
||||
| Situation | Winner | Vault-matching | Displayed |
|
||||
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
|
||||
|
||||
+16
-1
@@ -11,6 +11,21 @@
|
||||
|
||||
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
|
||||
|
||||
## Current technical references
|
||||
|
||||
- [Database Data Structures](datastructure.md) describes current Metadata and
|
||||
Chunk shapes, identifier handling, deletion, and raw remote representations.
|
||||
- [Replicator architecture](design_docs/replicator_architecture.md) describes
|
||||
provider composition, active Replicator publication, retirement, and P2P
|
||||
ownership.
|
||||
- [Conflict resolution and revision provenance](specs_conflict_resolution.md)
|
||||
defines the current revision-tree and file-provenance rules.
|
||||
- [Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md)
|
||||
defines missing-Chunk arrival and quiescence handling.
|
||||
- [Data Compression](specs_data_compression.md) and [Garbage Collection
|
||||
V3](specs_garbage_collection.md) describe their respective storage and
|
||||
maintenance contracts.
|
||||
|
||||
## Techniques to keep bandwidth consumption low.
|
||||
|
||||

|
||||

|
||||
|
||||
+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).
|
||||
|
||||
@@ -20,7 +20,7 @@ Enabling Hidden File Sync requires an initialisation direction:
|
||||
## Review the file selection
|
||||
|
||||
1. Open Self-hosted LiveSync settings.
|
||||
2. Open `Setup`, find `Enable extra and advanced features`, and enable `Advanced features`.
|
||||
2. Open `General Settings` → `Extra menus`, and enable `Advanced features`.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -36,9 +36,20 @@ Try these in order:
|
||||
1. Put both devices on the same ordinary network and retry.
|
||||
2. Remove a VPN temporarily if it blocks peer traffic, or use a trusted VPN such as Tailscale when it provides a reachable path between the devices.
|
||||
3. In `P2P Configuration` -> `Advanced Settings`, configure a trusted TURN service.
|
||||
4. Under `Connection compatibility`, select `TURN relay only` to test the configured TURN path without direct ICE candidates.
|
||||
|
||||
TURN is a fallback for encrypted WebRTC traffic. It is different from the required signalling relay. The project does not operate an official TURN service. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume.
|
||||
|
||||
For a small self-hosted deployment, the repository includes an optional [Coturn Compose starter](../../docker/coturn/README.md). It uses static credentials and does not include TLS or a managed credential service; review its network and security boundaries before exposing it.
|
||||
|
||||
## A connection opens but a transfer stalls
|
||||
|
||||
If peers can connect but a transfer repeatedly stalls on one network path, try the `P2P message size` presets under `Connection compatibility`. Start with `Reduced`, then try `Conservative` and `Maximum compatibility` only if needed.
|
||||
|
||||
The preset limits outgoing messages, so select the same value on every device which may send across the affected path. Smaller values add overhead and do not prove that packet fragmentation was the cause. Return to `Standard` when the path works reliably without the compatibility setting.
|
||||
|
||||
Compatibility choices are saved with the P2P profile. You may keep separate standard and compatibility profiles with the same Group ID and credentials, then select the profile appropriate to the current network.
|
||||
|
||||
## A connected peer does not receive later edits
|
||||
|
||||
An open signalling connection does not automatically move every change.
|
||||
|
||||
@@ -47,6 +47,14 @@ Do not switch to P2P or reset the database as the first response. Check:
|
||||
|
||||
If the remote is healthy but one device's local database is not, use [Reset Synchronisation on This Device](recovery.md#reset-synchronisation-on-this-device) only after backing up unsynchronised local files.
|
||||
|
||||
## Synchronisation is paused for compatibility review
|
||||
|
||||
A compatibility review is separate from the Change Log. It can appear after an internal database or settings-format change, or when a configured Vault is copied, restored, or opened in a new Obsidian profile without its device-local acknowledgement.
|
||||
|
||||
The **Synchronisation paused for compatibility review** dialogue opens after the Obsidian layout is ready. If it has been closed, use the persistent Notice's **Review why** link, or run `Review why synchronisation is paused` from the command palette. Opening **Change Log** does not clear the pause.
|
||||
|
||||
Review the stated reason before continuing. When **Resume synchronisation** is available, first update every synchronising device, then use that action to record the current internal database version and restore the configured synchronisation behaviour. If the action is unavailable, the running installation is older than the recorded database or settings format. Update that installation instead of resetting the database merely to remove the warning.
|
||||
|
||||
## Files are missing or excluded
|
||||
|
||||
Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files, file-size limits, modification-time limits, and Hidden File Sync rules. A filtered file is different from a file which reached the database but could not be reconstructed from its chunks.
|
||||
@@ -80,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
|
||||
|
||||
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
|
||||
|
||||
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
|
||||
|
||||
The available actions depend on when the mismatch is found:
|
||||
|
||||
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.16",
|
||||
"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
+206
-3192
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.16",
|
||||
"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",
|
||||
@@ -165,7 +165,6 @@
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.8",
|
||||
"webdriverio": "^9.27.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -178,8 +177,8 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.17",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.21",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
+39
-20
@@ -1,7 +1,11 @@
|
||||
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
type HasSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { __$checkInstanceBinding } from "@vrtmrz/livesync-commonlib/compat/dev/checks";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
@@ -11,17 +15,13 @@ import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces
|
||||
import type { LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
|
||||
import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
||||
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
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 { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -30,6 +30,16 @@ import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interface
|
||||
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";
|
||||
|
||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||
export interface LiveSyncCoreFeatureViews {
|
||||
readonly replicationScheduling: ReplicationSchedulingControl;
|
||||
}
|
||||
|
||||
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
|
||||
|
||||
export class LiveSyncBaseCore<
|
||||
T extends ServiceContext = ServiceContext,
|
||||
@@ -37,8 +47,6 @@ export class LiveSyncBaseCore<
|
||||
>
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncReplicatorEnv,
|
||||
LiveSyncJournalReplicatorEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
@@ -74,18 +82,22 @@ export class LiveSyncBaseCore<
|
||||
) => ServiceModules,
|
||||
extraModuleInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => AbstractModule[],
|
||||
addOnsInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => TCommands[],
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>) => void
|
||||
featuresInitialiser: (core: LiveSyncBaseCore<T, TCommands>, coreFeatureViews: LiveSyncCoreFeatureViews) => void
|
||||
) {
|
||||
this._services = serviceHub;
|
||||
this.registerReplicatorProviders();
|
||||
this._serviceModules = serviceModuleInitialiser(this, serviceHub);
|
||||
const extraModules = extraModuleInitialiser(this);
|
||||
this.registerModules(extraModules);
|
||||
this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this);
|
||||
const coreFeatureViews = this.initialiseServiceFeatures();
|
||||
featuresInitialiser(this, coreFeatureViews);
|
||||
const addOns = addOnsInitialiser(this);
|
||||
for (const addOn of addOns) {
|
||||
this._registerAddOn(addOn);
|
||||
}
|
||||
// Register host features and add-ons before replication, then bind
|
||||
// legacy modules so lifecycle handlers observe the required order.
|
||||
useReplicationFeature(this);
|
||||
this.bindModuleFunctions();
|
||||
}
|
||||
/**
|
||||
@@ -136,14 +148,17 @@ export class LiveSyncBaseCore<
|
||||
this.modules.push(module);
|
||||
}
|
||||
|
||||
/** Compose the current central providers before any lifecycle event can acquire one. */
|
||||
private registerReplicatorProviders() {
|
||||
this.services.replicator.registerReplicatorProviderDefinitions(
|
||||
createCentralReplicatorProviderDefinitions(this)
|
||||
);
|
||||
}
|
||||
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModulePeriodicProcess(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleBasicMenu(this));
|
||||
|
||||
@@ -223,10 +238,11 @@ export class LiveSyncBaseCore<
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time.
|
||||
* @obsolete Use the provider context or a focused service operation instead.
|
||||
* Provider-specific members on this compatibility view are optional.
|
||||
*/
|
||||
get replicator() {
|
||||
return this.services.replicator.getActiveReplicator()!;
|
||||
get replicator(): CompatibilityReplicatorView {
|
||||
return this.services.replicator.getActiveReplicator() as CompatibilityReplicatorView;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,12 +289,15 @@ export class LiveSyncBaseCore<
|
||||
* Initialise ServiceFeatures.
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures() {
|
||||
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
return Object.freeze({
|
||||
replicationScheduling: useReplicationScheduling(this),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-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.
|
||||
|
||||
@@ -71,6 +71,9 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||
- All remaining commands leave the settings file unchanged by default.
|
||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||
- `--compat-remote-admin-exit-zero`: Preserve the former zero exit code when `mark-resolved`, `lock-remote`, or `unlock-remote` returns a provider verification failure.
|
||||
- Without this option, those commands return a non-zero exit code when verification fails.
|
||||
- Invalid arguments, unknown remote IDs, and errors thrown while activating or mutating the remote remain errors with or without this option.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -96,6 +99,8 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `remote-status [remote-id]`: Show remote database status.
|
||||
- `init-settings [file]`: Create a default settings file.
|
||||
|
||||
Remote-administration commands verify the resulting milestone state through the selected provider. The existing `[Verification]` lines remain suitable for scripts which inspect command output, while the default exit code now reflects whether that verification succeeded.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
@@ -146,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:
|
||||
@@ -205,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:
|
||||
|
||||
@@ -338,6 +385,8 @@ Options:
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
|
||||
--write-settings Write setting changes after a successful command
|
||||
--compat-remote-admin-exit-zero
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
@@ -499,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`).
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
isCentralRemoteAdministrationVerified,
|
||||
type CentralRemoteAdministrationAction,
|
||||
type CentralRemoteAdministrationResult,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
|
||||
|
||||
const CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
|
||||
"mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
"lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
"unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
} as const satisfies Partial<Record<CLICommand, CentralRemoteAdministrationAction>>);
|
||||
|
||||
export type CentralRemoteAdministrationCommand = keyof typeof CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
|
||||
|
||||
/** Return whether a CLI command belongs to the central-remote administration category. */
|
||||
export function isCentralRemoteAdministrationCommand(
|
||||
command: CLICommand
|
||||
): command is CentralRemoteAdministrationCommand {
|
||||
return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
|
||||
}
|
||||
|
||||
function detailMessage(detail: unknown): string {
|
||||
return detail instanceof Error ? detail.message : String(detail);
|
||||
}
|
||||
|
||||
function assertNeverCentralRemoteAdministrationFailureReason(reason: never): never {
|
||||
throw new Error(`Unexpected central remote administration failure reason: ${String(reason)}`);
|
||||
}
|
||||
|
||||
function reportMilestoneObservation(
|
||||
standardIo: StandardIo,
|
||||
observation: Extract<
|
||||
CentralRemoteAdministrationResult["observation"],
|
||||
{ kind: typeof CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
|
||||
>
|
||||
): void {
|
||||
standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Current Device Node ID (${observation.nodeId}): ${observation.accepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
|
||||
);
|
||||
}
|
||||
|
||||
/** Map typed provider observations to the CLI's established verification output. */
|
||||
function reportCentralRemoteAdministrationResult(
|
||||
standardIo: StandardIo,
|
||||
result: CentralRemoteAdministrationResult
|
||||
): void {
|
||||
if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
|
||||
reportMilestoneObservation(standardIo, result.observation);
|
||||
return;
|
||||
}
|
||||
if (isCentralRemoteAdministrationVerified(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = result.reason;
|
||||
switch (reason) {
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to connect to the configured remote: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH:
|
||||
standardIo.writeStderr(
|
||||
"[Verification] The active remote configuration changed before remote administration could begin.\n"
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
|
||||
);
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
|
||||
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
|
||||
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
|
||||
return;
|
||||
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
|
||||
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
|
||||
return;
|
||||
default:
|
||||
return assertNeverCentralRemoteAdministrationFailureReason(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
|
||||
* Mutation exceptions deliberately escape this boundary.
|
||||
*/
|
||||
export async function runCentralRemoteAdministrationCommand(
|
||||
options: CLIOptions,
|
||||
context: CLICommandContext,
|
||||
command: CentralRemoteAdministrationCommand
|
||||
): Promise<boolean> {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await context.core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
context.core.services.context.standardIo.writeStderr(
|
||||
`[Info] Failed to temporarily activate remote configuration: ${id}\n`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await context.core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
|
||||
const action = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
|
||||
const result = await context.core.services.replicator.runCentralRemoteAdministration({ action });
|
||||
reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result);
|
||||
return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
|
||||
@@ -38,7 +39,7 @@ function createCoreMock() {
|
||||
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => true),
|
||||
replicateUnattended: vi.fn(async () => ({ status: "completed" as const })),
|
||||
},
|
||||
appLifecycle: {
|
||||
onUnload: {
|
||||
@@ -87,6 +88,17 @@ const baseContext = {
|
||||
},
|
||||
} as any;
|
||||
|
||||
function createDaemonContext(core: ReturnType<typeof createCoreMock>) {
|
||||
return {
|
||||
...baseContext,
|
||||
core,
|
||||
replicationScheduling: {
|
||||
setExternalPollingMode: vi.fn(),
|
||||
markInitialOneShotSatisfied: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -101,7 +113,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -110,7 +122,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
@@ -120,9 +132,11 @@ describe("daemon command", () => {
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(30), context);
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
expect(context.replicationScheduling.setExternalPollingMode).toHaveBeenCalledWith(true);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
@@ -131,7 +145,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
@@ -144,7 +158,7 @@ describe("daemon command", () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -164,7 +178,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
@@ -182,7 +196,7 @@ describe("daemon command", () => {
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
|
||||
([chunk]: [string | Uint8Array]) =>
|
||||
@@ -194,37 +208,50 @@ describe("daemon command", () => {
|
||||
it("calls replicate before performFullScan", async () => {
|
||||
const core = createCoreMock();
|
||||
const callOrder: string[] = [];
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callOrder.push("replicate");
|
||||
return true;
|
||||
return { status: "completed" as const };
|
||||
});
|
||||
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||
callOrder.push("performFullScan");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const context = createDaemonContext(core);
|
||||
await runCommand(makeDaemonOptions(), context);
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(context.replicationScheduling.markInitialOneShotSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicate = vi.fn(async () => false);
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => ({
|
||||
status: "failed" as const,
|
||||
error: new Error("initial replication failed"),
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
const result = await runCommand(makeDaemonOptions(), createDaemonContext(core));
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||
expect(core.services.replication.replicateUnattended).toHaveBeenCalledWith({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(10), createDaemonContext(core));
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
@@ -242,17 +269,17 @@ describe("daemon command", () => {
|
||||
|
||||
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // initial startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // initial startup replicate
|
||||
if (callCount <= 7) throw new Error("network failure");
|
||||
return true; // recovery
|
||||
return { status: "completed" as const }; // recovery
|
||||
});
|
||||
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
@@ -297,14 +324,14 @@ describe("daemon command", () => {
|
||||
|
||||
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
core.services.replication.replicateUnattended = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // startup replicate
|
||||
if (callCount === 1) return { status: "completed" as const }; // startup replicate
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
await runCommand(makeDaemonOptions(30), createDaemonContext(core));
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { P2P_DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { getPeerConnectionStats } from "@vrtmrz/livesync-commonlib/compat/rpc/transports/DiagRTCPeerConnections.utils";
|
||||
import type { P2PPeerConnectionMetrics, P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { fsPromises } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
type CLIP2PPeer = {
|
||||
@@ -12,17 +11,13 @@ type CLIP2PPeer = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CandidateSummary = {
|
||||
id: string;
|
||||
candidateType: string;
|
||||
protocol: string;
|
||||
relayProtocol: string;
|
||||
};
|
||||
type CLIP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer" | "diagnostics">;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Parse a CLI timeout expressed as a finite, non-negative number of seconds. */
|
||||
export function parseTimeoutSeconds(value: string, commandName: string): number {
|
||||
const timeoutSec = Number(value);
|
||||
if (!Number.isFinite(timeoutSec) || timeoutSec < 0) {
|
||||
@@ -43,35 +38,36 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, never>) {
|
||||
settings.P2P_IsHeadless = true;
|
||||
}
|
||||
|
||||
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
function requireP2PService(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
service: CLIP2PService | undefined
|
||||
): CLIP2PService {
|
||||
validateP2PSettings(core);
|
||||
const replicator = await core.services.replicator.getNewReplicator();
|
||||
if (!replicator) {
|
||||
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
|
||||
if (!service) {
|
||||
throw new Error("P2P service is not available. Ensure the P2P feature was composed for this CLI process.");
|
||||
}
|
||||
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
|
||||
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
|
||||
}
|
||||
return replicator;
|
||||
return service;
|
||||
}
|
||||
|
||||
function getSortedPeers(replicator: LiveSyncTrysteroReplicator): CLIP2PPeer[] {
|
||||
return [...replicator.knownAdvertisements]
|
||||
function getSortedPeers(service: Pick<P2PServiceViews, "peerDirectory">): CLIP2PPeer[] {
|
||||
return [...service.peerDirectory.getPeers()]
|
||||
.map((peer) => ({ peerId: peer.peerId, name: peer.name }))
|
||||
.sort((a, b) => a.peerId.localeCompare(b.peerId));
|
||||
}
|
||||
|
||||
/** Connect for a bounded discovery interval, return a stable peer ordering, and disconnect. */
|
||||
export async function collectPeers(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer[]> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
await delay(timeoutSec * 1000);
|
||||
return getSortedPeers(replicator);
|
||||
return getSortedPeers(service);
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,32 +86,8 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getReportValue<T extends string | number>(
|
||||
report: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): T | "unknown" {
|
||||
const value = report?.[key];
|
||||
return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown";
|
||||
}
|
||||
|
||||
function summariseCandidate(reports: unknown[], candidateId: string): CandidateSummary | undefined {
|
||||
if (candidateId === "unknown") {
|
||||
return undefined;
|
||||
}
|
||||
const report = reports.map((r) => r as Record<string, unknown>).find((r) => r.id === candidateId);
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: candidateId,
|
||||
candidateType: getReportValue<string>(report, "candidateType"),
|
||||
protocol: getReportValue<string>(report, "protocol"),
|
||||
relayProtocol: getReportValue<string>(report, "relayProtocol"),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePeerConnectionStatsIfRequested(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: Pick<P2PServiceViews, "diagnostics">,
|
||||
peer: CLIP2PPeer
|
||||
): Promise<void> {
|
||||
const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim();
|
||||
@@ -123,21 +95,30 @@ async function writePeerConnectionStatsIfRequested(
|
||||
return;
|
||||
}
|
||||
|
||||
const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId];
|
||||
const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined;
|
||||
const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown");
|
||||
const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown");
|
||||
const stats = await service.diagnostics.getPeerConnectionMetrics(peer.peerId);
|
||||
const payload = createPeerConnectionStatsPayload(peer, stats, new Date().toISOString());
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/** Build the stable JSONL record consumed by the P2P benchmark harnesses. */
|
||||
export function createPeerConnectionStatsPayload(
|
||||
peer: CLIP2PPeer,
|
||||
stats: P2PPeerConnectionMetrics | undefined,
|
||||
generatedAt: string
|
||||
) {
|
||||
const localCandidate = stats?.localCandidate;
|
||||
const remoteCandidate = stats?.remoteCandidate;
|
||||
const selectedPath =
|
||||
localCandidate && remoteCandidate
|
||||
? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}`
|
||||
: "unknown";
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedAt,
|
||||
command: "p2p-sync",
|
||||
peerId: peer.peerId,
|
||||
peerName: peer.name,
|
||||
candidatePathCollected: !!stats?.selectedPair,
|
||||
candidatePathCollected: stats?.selectedPairPresent ?? false,
|
||||
selectedPath,
|
||||
selectedPair: stats
|
||||
? {
|
||||
@@ -155,23 +136,25 @@ async function writePeerConnectionStatsIfRequested(
|
||||
localCandidate,
|
||||
remoteCandidate,
|
||||
};
|
||||
await fsPromises.appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8");
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Resolve one peer token, complete pull then push, and disconnect on every settlement. */
|
||||
export async function syncWithPeer(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined,
|
||||
peerToken: string,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const start = Date.now();
|
||||
let targetPeer: CLIP2PPeer | undefined;
|
||||
|
||||
while (Date.now() - start <= timeoutMs) {
|
||||
const peers = getSortedPeers(replicator);
|
||||
const peers = getSortedPeers(service);
|
||||
targetPeer = resolvePeer(peers, peerToken);
|
||||
if (targetPeer) {
|
||||
break;
|
||||
@@ -183,11 +166,14 @@ export async function syncWithPeer(
|
||||
throw new Error(`Peer '${peerToken}' was not found within ${timeoutSec} seconds`);
|
||||
}
|
||||
|
||||
const pullResult = await replicator.replicateFrom(targetPeer.peerId, false);
|
||||
const pullResult = await service.targetedTransfer.pullFromPeer(targetPeer.peerId, { showNotice: false });
|
||||
if (pullResult && "error" in pullResult && pullResult.error) {
|
||||
throw pullResult.error instanceof Error ? pullResult.error : LiveSyncError.fromError(pullResult.error);
|
||||
}
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(targetPeer.peerId);
|
||||
if (!pullResult || pullResult.status !== "completed") {
|
||||
throw LiveSyncError.fromError("P2P sync failed while pulling from peer");
|
||||
}
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(targetPeer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
const err: unknown = pushResult && "error" in pushResult ? pushResult.error : undefined;
|
||||
throw err instanceof Error
|
||||
@@ -195,15 +181,19 @@ export async function syncWithPeer(
|
||||
: LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync");
|
||||
}
|
||||
|
||||
await writePeerConnectionStatsIfRequested(replicator, targetPeer);
|
||||
await writePeerConnectionStatsIfRequested(service, targetPeer);
|
||||
return targetPeer;
|
||||
} finally {
|
||||
await replicator.close();
|
||||
await service.transportLifecycle.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, never>): Promise<LiveSyncTrysteroReplicator> {
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
return replicator;
|
||||
/** Connect the headless P2P host and transfer transport ownership to the caller. */
|
||||
export async function openP2PHost(
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
p2pService: CLIP2PService | undefined
|
||||
): Promise<CLIP2PService> {
|
||||
const service = requireP2PService(core, p2pService);
|
||||
await service.transportLifecycle.connect();
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTimeoutSeconds } from "./p2p";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { collectPeers, createPeerConnectionStatsPayload, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
|
||||
function createCore() {
|
||||
const settings = { P2P_Enabled: true, P2P_AppID: "app-id", P2P_IsHeadless: false };
|
||||
return {
|
||||
services: {
|
||||
setting: { currentSettings: () => settings },
|
||||
replicator: { getNewReplicator: vi.fn(() => Promise.reject(new Error("must not be called"))) },
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function createP2PService() {
|
||||
const connect = vi.fn(async () => undefined);
|
||||
const disconnect = vi.fn(async () => undefined);
|
||||
const pullFromPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
|
||||
const requestPushToPeer = vi.fn(async () => ({ status: "completed" as const, ok: true as const }));
|
||||
return {
|
||||
service: {
|
||||
transportLifecycle: { isConnected: false, connect, disconnect },
|
||||
peerDirectory: {
|
||||
getPeers: () => [{ peerId: "peer-a", name: "Peer A", platform: "test" }],
|
||||
},
|
||||
targetedTransfer: {
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
synchroniseWithPeer: vi.fn(),
|
||||
},
|
||||
diagnostics: { requestStatus: vi.fn(), getPeerConnectionMetrics: vi.fn() },
|
||||
},
|
||||
connect,
|
||||
disconnect,
|
||||
pullFromPeer,
|
||||
requestPushToPeer,
|
||||
};
|
||||
}
|
||||
|
||||
describe("p2p command helpers", () => {
|
||||
it("accepts non-negative timeout", () => {
|
||||
@@ -15,4 +50,98 @@ describe("p2p command helpers", () => {
|
||||
"p2p-sync requires a non-negative timeout in seconds"
|
||||
);
|
||||
});
|
||||
|
||||
it("collects peers through service views without acquiring a concrete replicator", async () => {
|
||||
const { service, connect, disconnect } = createP2PService();
|
||||
|
||||
await expect(collectPeers(createCore(), service as never, 0)).resolves.toEqual([
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
]);
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("synchronises through the targeted-transfer view", async () => {
|
||||
const { service, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).resolves.toEqual({
|
||||
peerId: "peer-a",
|
||||
name: "Peer A",
|
||||
});
|
||||
expect(pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: false });
|
||||
expect(requestPushToPeer).toHaveBeenCalledWith("peer-a");
|
||||
});
|
||||
|
||||
it("rejects a cancelled pull without requesting a peer push", async () => {
|
||||
const { service, disconnect, pullFromPeer, requestPushToPeer } = createP2PService();
|
||||
pullFromPeer.mockResolvedValue({ status: "cancelled" } as never);
|
||||
|
||||
await expect(syncWithPeer(createCore(), service as never, "peer-a", 0)).rejects.toBeDefined();
|
||||
|
||||
expect(requestPushToPeer).not.toHaveBeenCalled();
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves the benchmark diagnostics JSONL contract", () => {
|
||||
expect(
|
||||
createPeerConnectionStatsPayload(
|
||||
{ peerId: "peer-a", name: "Peer A" },
|
||||
{
|
||||
selectedPairPresent: true,
|
||||
selectedPairId: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
},
|
||||
"2026-08-27T00:00:00.000Z"
|
||||
)
|
||||
).toEqual({
|
||||
generatedAt: "2026-08-27T00:00:00.000Z",
|
||||
command: "p2p-sync",
|
||||
peerId: "peer-a",
|
||||
peerName: "Peer A",
|
||||
candidatePathCollected: true,
|
||||
selectedPath: "host<->relay",
|
||||
selectedPair: {
|
||||
id: "pair-1",
|
||||
state: "succeeded",
|
||||
currentRoundTripTime: 0.01,
|
||||
totalRoundTripTime: 0.1,
|
||||
requestsSent: 3,
|
||||
responsesReceived: 3,
|
||||
packetsDiscardedOnSend: 0,
|
||||
bytesSent: 100,
|
||||
bytesReceived: 200,
|
||||
},
|
||||
localCandidate: {
|
||||
id: "local-1",
|
||||
candidateType: "host",
|
||||
protocol: "udp",
|
||||
relayProtocol: "unknown",
|
||||
},
|
||||
remoteCandidate: {
|
||||
id: "remote-1",
|
||||
candidateType: "relay",
|
||||
protocol: "udp",
|
||||
relayProtocol: "udp",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,8 @@ import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/AP
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
MILESTONE_DOCID,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type EntryMilestoneInfo,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
@@ -23,71 +19,27 @@ import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatur
|
||||
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";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
isReplicationCompleted,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||
import {
|
||||
isCentralRemoteAdministrationCommand,
|
||||
runCentralRemoteAdministrationCommand,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
||||
}
|
||||
|
||||
async function verifyRemoteState(
|
||||
core: CLICommandContext["core"],
|
||||
settings: ObsidianLiveSyncSettings
|
||||
): Promise<boolean> {
|
||||
const { standardIo } = core.services.context;
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!replicator.nodeid) {
|
||||
await replicator.initializeDatabaseForReplication();
|
||||
}
|
||||
|
||||
try {
|
||||
let milestone: EntryMilestoneInfo | false | undefined = undefined;
|
||||
if (settings.remoteType === REMOTE_COUCHDB) {
|
||||
const dbRet = await (replicator as LiveSyncCouchDBReplicator).connectRemoteCouchDBWithSetting(
|
||||
settings,
|
||||
false,
|
||||
true
|
||||
);
|
||||
if (typeof dbRet === "string") {
|
||||
standardIo.writeStderr(`[Verification] Failed to connect to remote CouchDB: ${dbRet}\n`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
||||
} finally {
|
||||
await dbRet.db.close();
|
||||
}
|
||||
} else if (settings.remoteType === REMOTE_MINIO) {
|
||||
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
|
||||
}
|
||||
|
||||
if (milestone) {
|
||||
const isLocked = !!milestone.locked;
|
||||
const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid);
|
||||
standardIo.writeStderr(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||
standardIo.writeStderr(
|
||||
`[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
standardIo.writeStderr(`[Verification] Failed to fetch milestone document: ${message}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
const { databasePath, core, replicationScheduling, settingsPath } = context;
|
||||
const { standardIo } = core.services.context;
|
||||
const vaultPath = context.vaultPath || databasePath;
|
||||
|
||||
@@ -95,19 +47,28 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.command === "daemon") {
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Daemon] ${String(msg)}`);
|
||||
|
||||
// The daemon owns its own recurring poller. Suppress the application
|
||||
// resume starter and generic periodic timer before restoring settings.
|
||||
replicationScheduling.setExternalPollingMode(!!options.interval);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
// accept whatever configuration the remote has.
|
||||
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||
|
||||
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||
log("Replicating from CouchDB...");
|
||||
const replResult = await core.services.replication.replicate(true);
|
||||
if (!replResult) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||
// 1. Replicate the configured remote into the local database so the
|
||||
// mirror scan has content to work with.
|
||||
log("Replicating from remote...");
|
||||
const replResult = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(replResult)) {
|
||||
writeStderrLine(standardIo, "[Daemon] Initial replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("CouchDB replication complete");
|
||||
replicationScheduling.markInitialOneShotSatisfied();
|
||||
log("Initial replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
@@ -129,8 +90,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
true
|
||||
);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||
// via fireAndForget.
|
||||
// The provider-independent scheduling feature owns any eligible
|
||||
// Continuous start; the daemon marker suppresses a duplicate
|
||||
// sync-on-start OneShot.
|
||||
await core.services.control.applySettings();
|
||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||
@@ -153,7 +115,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await core.services.replication.replicate(true);
|
||||
const result = await core.services.replication.replicateUnattended({
|
||||
trigger: "daemon",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
throw new Error(`Daemon polling replication did not complete (${result.status}).`);
|
||||
}
|
||||
if (consecutiveFailures > 0) {
|
||||
consecutiveFailures--;
|
||||
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||
@@ -182,11 +150,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||
log("LiveSync mode: restoring sync settings and starting continuous synchronisation where supported");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||
// The applySettings() lifecycle fires onResumed → the provider-
|
||||
// independent scheduling feature, which starts Continuous when
|
||||
// supported. Do not call a concrete Replicator directly.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||
@@ -204,13 +172,20 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
if (options.command === "sync") {
|
||||
writeStdoutLine(standardIo, "[Command] sync");
|
||||
const result = await core.services.replication.replicate(true);
|
||||
if (!result) {
|
||||
const result = await core.services.replication.replicateUserInitiated({
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
if (!isReplicationCompleted(result)) {
|
||||
// TODO: Standardise the logic for identifying the cause of replication
|
||||
// failure so that every reason (locked DB, version mismatch, network
|
||||
// error, etc.) is surfaced with a CLI-specific actionable message.
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined;
|
||||
if (
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||
) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The remote database is locked and this device is not yet accepted.\n` +
|
||||
@@ -218,7 +193,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
);
|
||||
}
|
||||
}
|
||||
return !!result;
|
||||
return isReplicationCompleted(result);
|
||||
}
|
||||
|
||||
if (options.command === "p2p-peers") {
|
||||
@@ -227,7 +202,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[0], "p2p-peers");
|
||||
writeStderrLine(standardIo, `[Command] p2p-peers timeout=${timeoutSec}s`);
|
||||
const peers = await collectPeers(core, timeoutSec);
|
||||
const peers = await collectPeers(core, context.p2pReplicator, timeoutSec);
|
||||
if (peers.length > 0) {
|
||||
standardIo.writeStdout(peers.map((peer) => `[peer]\t${peer.peerId}\t${peer.name}`).join("\n") + "\n");
|
||||
}
|
||||
@@ -244,14 +219,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "p2p-sync");
|
||||
writeStderrLine(standardIo, `[Command] p2p-sync peer=${peerToken} timeout=${timeoutSec}s`);
|
||||
const peer = await syncWithPeer(core, peerToken, timeoutSec);
|
||||
const peer = await syncWithPeer(core, context.p2pReplicator, peerToken, timeoutSec);
|
||||
writeStderrLine(standardIo, `[Done] P2P sync completed with ${peer.name} (${peer.peerId})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "p2p-host") {
|
||||
writeStderrLine(standardIo, "[Command] p2p-host");
|
||||
await openP2PHost(core);
|
||||
await openP2PHost(core, context.p2pReplicator);
|
||||
writeStderrLine(standardIo, "[Ready] P2P host is running. Press Ctrl+C to stop.");
|
||||
await new Promise(() => {});
|
||||
return true;
|
||||
@@ -757,88 +732,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "mark-resolved") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] mark-resolved${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markResolved();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "unlock-remote") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] unlock-remote${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markUnlocked();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "lock-remote") {
|
||||
const id = options.commandArgs[0]?.trim();
|
||||
if (id) {
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, false);
|
||||
|
||||
if (!switched) {
|
||||
standardIo.writeStderr(`[Info] Failed to temporarily activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`);
|
||||
await core.services.replication.markLocked();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
await verifyRemoteState(core, settings);
|
||||
return true;
|
||||
if (isCentralRemoteAdministrationCommand(options.command)) {
|
||||
return await runCentralRemoteAdministrationCommand(options, context, options.command);
|
||||
}
|
||||
|
||||
if (options.command === "remote-status") {
|
||||
@@ -863,13 +758,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
}
|
||||
|
||||
writeStderrLine(standardIo, `[Command] remote-status${id ? ` ${id}` : ""}`);
|
||||
const replicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
standardIo.writeStderr("[Error] No active replicator found\n");
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const resource = await core.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
settings
|
||||
);
|
||||
if (!resource) {
|
||||
standardIo.writeStderr("[Error] Remote status is unavailable for the current provider\n");
|
||||
return false;
|
||||
}
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const status = await replicator.getRemoteStatus(settings);
|
||||
const status = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.getStatus());
|
||||
if (status === false) {
|
||||
standardIo.writeStderr("[Error] Failed to fetch remote status\n");
|
||||
return false;
|
||||
|
||||
@@ -2,10 +2,26 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
@@ -44,8 +60,26 @@ function createCoreMock() {
|
||||
markResolved: vi.fn(async () => {}),
|
||||
markUnlocked: vi.fn(async () => {}),
|
||||
markLocked: vi.fn(async () => {}),
|
||||
replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
},
|
||||
replicator: {
|
||||
runCentralRemoteAdministration: vi.fn(async ({ action }) => ({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: action === CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
accepted: true,
|
||||
nodeId: "test-node-id",
|
||||
},
|
||||
})),
|
||||
createRemoteResource: vi.fn(async () => ({
|
||||
check: vi.fn(async () => ({ ok: true as const })),
|
||||
getStatus: vi.fn(async () => ({
|
||||
db_name: "test-db",
|
||||
doc_count: 42,
|
||||
})),
|
||||
dispose: vi.fn(async () => undefined),
|
||||
})),
|
||||
getActiveReplicator: vi.fn(() => ({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
@@ -93,6 +127,7 @@ function makeOptions(command: CLIOptions["command"], commandArgs: string[]): CLI
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
compatRemoteAdminExitZero: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,6 +266,42 @@ describe("runCommand abnormal cases", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("retains visible progress for the interactive sync command", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE })
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||
replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
runCommand(makeOptions("sync", []), {
|
||||
...context,
|
||||
core,
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("remote database is locked")
|
||||
);
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pull returns false for non-existing path", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||
@@ -706,28 +777,158 @@ describe("runCommand abnormal cases", () => {
|
||||
});
|
||||
|
||||
describe("mark-resolved and unlock-remote commands", () => {
|
||||
it("reports a connection failure without claiming that every central remote is CouchDB", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: new Error("remote unavailable"),
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain(
|
||||
"[Verification] Failed to connect to the configured remote: remote unavailable\n"
|
||||
);
|
||||
expect(verificationOutput).not.toContain("CouchDB");
|
||||
});
|
||||
|
||||
it("reports when the active remote configuration changes before administration begins", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.ACTIVE_CONFIGURATION_MISMATCH,
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain(
|
||||
"[Verification] The active remote configuration changed before remote administration could begin.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("fails by default when remote administration cannot verify its postcondition", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the historical zero exit for returned verification failures only when requested", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||
});
|
||||
|
||||
const result = await runCommand(
|
||||
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("does not hide a thrown remote mutation failure behind the compatibility option", async () => {
|
||||
const core = createCoreMock();
|
||||
const failure = new Error("mutation failed");
|
||||
core.services.replicator.runCentralRemoteAdministration.mockRejectedValueOnce(failure);
|
||||
|
||||
await expect(
|
||||
runCommand(
|
||||
{ ...makeOptions("mark-resolved", []), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it("does not hide an unknown remote ID behind the compatibility option", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
const result = await runCommand(
|
||||
{ ...makeOptions("mark-resolved", ["missing-remote"]), compatRemoteAdminExitZero: true },
|
||||
{
|
||||
...context,
|
||||
core,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails a lock command when the observed milestone remains unlocked", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: false,
|
||||
accepted: true,
|
||||
nodeId: "test-node-id",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("lock-remote", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||
.map(([chunk]: [string | Uint8Array]) =>
|
||||
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||
)
|
||||
.join("");
|
||||
expect(verificationOutput).toContain("[Verification] Remote Database: UNLOCKED\n");
|
||||
expect(verificationOutput).toContain("[Verification] Current Device Node ID (test-node-id): ACCEPTED\n");
|
||||
});
|
||||
|
||||
it("mark-resolved without args runs on active database", async () => {
|
||||
const core = createCoreMock();
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
get: vi.fn(async () => ({
|
||||
locked: false,
|
||||
accepted_nodes: ["test-node-id"],
|
||||
})),
|
||||
};
|
||||
core.services.replicator.getActiveReplicator.mockReturnValueOnce({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
|
||||
});
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
expect(remoteDatabase.close).toHaveBeenCalledOnce();
|
||||
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
||||
@@ -745,7 +946,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markResolved).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -758,7 +961,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -777,7 +982,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markUnlocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -790,7 +997,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -809,7 +1018,9 @@ describe("runCommand abnormal cases", () => {
|
||||
core,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.replication.markLocked).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
});
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
expect(settings.activeConfigurationId).toBe("r1");
|
||||
expect(core.services.setting.updateSettings).toHaveBeenCalledWith(expect.any(Function), false);
|
||||
@@ -817,6 +1028,17 @@ describe("runCommand abnormal cases", () => {
|
||||
|
||||
it("remote-status without args outputs status of active remote configuration", async () => {
|
||||
const core = createCoreMock();
|
||||
const getStatus = vi.fn(async () => ({
|
||||
db_name: "test-db",
|
||||
doc_count: 42,
|
||||
}));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({
|
||||
check: vi.fn(),
|
||||
getStatus,
|
||||
dispose,
|
||||
}));
|
||||
core.services.replicator.createRemoteResource = createRemoteResource;
|
||||
const stdout = captureStdout(core);
|
||||
const result = await runCommand(makeOptions("remote-status", []), {
|
||||
...context,
|
||||
@@ -827,6 +1049,13 @@ describe("runCommand abnormal cases", () => {
|
||||
const parsedStatus = JSON.parse(fullOutput);
|
||||
expect(parsedStatus.db_name).toBe("test-db");
|
||||
expect(parsedStatus.doc_count).toBe(42);
|
||||
expect(createRemoteResource).toHaveBeenCalledWith(
|
||||
REMOTE_RESOURCE_KINDS.CONNECTION,
|
||||
core.services.setting.currentSettings()
|
||||
);
|
||||
expect(getStatus).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-status with remote-id temporarily activates it and outputs status", async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -41,6 +42,8 @@ export interface CLIOptions {
|
||||
debug?: boolean;
|
||||
force?: boolean;
|
||||
writeSettings?: boolean;
|
||||
/** Restore the former zero exit code after a returned remote-administration verification failure. */
|
||||
compatRemoteAdminExitZero?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
@@ -50,6 +53,8 @@ export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
vaultPath: string;
|
||||
core: LiveSyncBaseCore<NodeServiceContext, never>;
|
||||
/** Host-composition view used only to coordinate daemon-owned recurring work. */
|
||||
replicationScheduling: ReplicationSchedulingControl;
|
||||
/** Current-result contract owned by the P2P service feature. */
|
||||
p2pReplicator?: UseP2PReplicatorResult;
|
||||
settingsPath: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-5
@@ -23,8 +23,8 @@ import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types
|
||||
import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
@@ -103,6 +103,8 @@ Options:
|
||||
(defaults to database-path; allows separate PouchDB and vault dirs)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--write-settings Write setting changes after a successful command
|
||||
--compat-remote-admin-exit-zero
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
@@ -153,6 +155,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let writeSettings = false;
|
||||
let compatRemoteAdminExitZero = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
@@ -212,6 +215,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
case "--write-settings":
|
||||
writeSettings = true;
|
||||
break;
|
||||
case "--compat-remote-admin-exit-zero":
|
||||
compatRemoteAdminExitZero = true;
|
||||
break;
|
||||
default: {
|
||||
if (!databasePath) {
|
||||
if (command === "daemon" && isCLICommand(token)) {
|
||||
@@ -253,6 +259,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
debug,
|
||||
force,
|
||||
writeSettings,
|
||||
compatRemoteAdminExitZero,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
@@ -290,7 +297,10 @@ export async function main(
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
|
||||
);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
@@ -420,7 +430,10 @@ export async function main(
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Info] Replication result received, but not processed automatically in CLI mode.`
|
||||
);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
@@ -472,6 +485,7 @@ export async function main(
|
||||
|
||||
// Create LiveSync core
|
||||
let p2pReplicator: UseP2PReplicatorResult | undefined;
|
||||
let replicationScheduling: ReplicationSchedulingControl | undefined;
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, never>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
@@ -479,7 +493,8 @@ export async function main(
|
||||
},
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
(core, coreFeatureViews) => {
|
||||
replicationScheduling = coreFeatureViews.replicationScheduling;
|
||||
// Register P2P replicator feature.
|
||||
p2pReplicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
@@ -511,6 +526,9 @@ export async function main(
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!replicationScheduling) {
|
||||
throw new Error("Replication scheduling was not provided during core feature composition.");
|
||||
}
|
||||
|
||||
// Setup signal handlers for graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -617,6 +635,7 @@ export async function main(
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
replicationScheduling,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("CLI parseArgs", () => {
|
||||
const combined = standardIo.writeStdout.mock.calls.flat().join("");
|
||||
expect(combined).toContain("Usage:");
|
||||
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
|
||||
expect(combined).toContain("--compat-remote-admin-exit-zero");
|
||||
});
|
||||
|
||||
it("parses p2p-peers command and timeout", () => {
|
||||
@@ -215,4 +216,13 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.writeSettings).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses the remote-administration exit compatibility option globally", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--compat-remote-admin-exit-zero", "mark-resolved"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.command).toBe("mark-resolved");
|
||||
expect(parsed.compatRemoteAdminExitZero).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.16-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",
|
||||
@@ -48,7 +48,7 @@
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"werift": "^0.23.0"
|
||||
"werift": "^0.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.3",
|
||||
|
||||
@@ -111,6 +111,7 @@ export function initialiseServiceModulesCLI(
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||
import { main, type CliCommandRunner } from "@/apps/cli/main";
|
||||
import { parseTimeoutSeconds } from "@/apps/cli/commands/p2p";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement";
|
||||
import { runP2PReplicatorReplacementProbe } from "./p2p-replicator-replacement.test";
|
||||
|
||||
if (
|
||||
typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" &&
|
||||
|
||||
+44
-35
@@ -1,6 +1,6 @@
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import type { CLICommandContext } from "@/apps/cli/commands/types";
|
||||
import { openP2PHost } from "@/apps/cli/commands/p2p";
|
||||
|
||||
@@ -15,32 +15,35 @@ function describeError(value: unknown): string {
|
||||
return value instanceof Error ? (value.stack ?? value.message) : String(value);
|
||||
}
|
||||
|
||||
async function waitForServing(replicator: LiveSyncTrysteroReplicator, timeoutMs: number): Promise<void> {
|
||||
type ProbeP2PService = Pick<P2PServiceViews, "transportLifecycle" | "peerDirectory" | "targetedTransfer">;
|
||||
|
||||
async function waitForServing(service: ProbeP2PService, timeoutMs: number): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
if (replicator.server?.isServing) return;
|
||||
if (service.transportLifecycle.isConnected) return;
|
||||
await delay(200);
|
||||
}
|
||||
throw new Error("The replacement P2P replicator did not start serving within the timeout");
|
||||
throw new Error("The stable P2P service did not start serving within the timeout");
|
||||
}
|
||||
|
||||
async function waitForPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started <= timeoutMs) {
|
||||
const peer = replicator.knownAdvertisements.find(
|
||||
(candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer
|
||||
);
|
||||
const peer = service.peerDirectory
|
||||
.getPeers()
|
||||
.find((candidate) => candidate.name === targetPeer || candidate.peerId === targetPeer);
|
||||
if (peer) return peer;
|
||||
await delay(200);
|
||||
}
|
||||
const knownPeers = replicator.knownAdvertisements.map((peer) => `${peer.name} (${peer.peerId})`).join(", ");
|
||||
throw new Error(
|
||||
`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`
|
||||
);
|
||||
const knownPeers = service.peerDirectory
|
||||
.getPeers()
|
||||
.map((peer) => `${peer.name} (${peer.peerId})`)
|
||||
.join(", ");
|
||||
throw new Error(`Peer '${targetPeer}' was not discovered within the timeout. Known peers: ${knownPeers || "none"}`);
|
||||
}
|
||||
|
||||
function assertPullSucceeded(result: unknown): void {
|
||||
@@ -50,15 +53,17 @@ function assertPullSucceeded(result: unknown): void {
|
||||
}
|
||||
|
||||
async function communicateWithPeer(
|
||||
replicator: LiveSyncTrysteroReplicator,
|
||||
service: ProbeP2PService,
|
||||
targetPeer: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ peerId: string; name: string }> {
|
||||
await replicator.open();
|
||||
await waitForServing(replicator, timeoutMs);
|
||||
const peer = await waitForPeer(replicator, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await replicator.replicateFrom(peer.peerId, false));
|
||||
const pushResult = await replicator.requestSynchroniseToPeer(peer.peerId);
|
||||
if (!service.transportLifecycle.isConnected) {
|
||||
await service.transportLifecycle.connect();
|
||||
}
|
||||
await waitForServing(service, timeoutMs);
|
||||
const peer = await waitForPeer(service, targetPeer, timeoutMs);
|
||||
assertPullSucceeded(await service.targetedTransfer.pullFromPeer(peer.peerId, { showNotice: false }));
|
||||
const pushResult = await service.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
if (!pushResult || pushResult.ok !== true) {
|
||||
throw new Error(`P2P push failed: ${describeError(pushResult?.error)}`);
|
||||
}
|
||||
@@ -78,35 +83,39 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
throw new Error("The CLI did not expose its P2P service-feature result to the integration probe");
|
||||
}
|
||||
|
||||
const firstReplicator = await openP2PHost(core);
|
||||
if (p2pReplicator.replicator !== firstReplicator) {
|
||||
throw new Error("The P2P service feature did not expose the newly created replicator");
|
||||
const initialActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!initialActiveReplicator) {
|
||||
throw new Error("The CLI did not activate the initial P2P Replicator adapter");
|
||||
}
|
||||
const compatibilityFacade = p2pReplicator.replicator;
|
||||
const p2pService = await openP2PHost(core, p2pReplicator);
|
||||
|
||||
const firstPeer = await communicateWithPeer(firstReplicator, targetPeer, timeoutMs);
|
||||
const firstPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
const initialised = await core.services.databaseEvents.initialiseDatabase(false, true, false);
|
||||
if (!initialised) {
|
||||
throw new Error("Database reinitialisation failed during the P2P replacement probe");
|
||||
}
|
||||
|
||||
const replacementReplicator = p2pReplicator.replicator;
|
||||
if (core.services.replicator.getActiveReplicator() !== replacementReplicator) {
|
||||
throw new Error("ReplicatorService did not activate the P2P service feature's replacement replicator");
|
||||
const replacementActiveReplicator = core.services.replicator.getActiveReplicator();
|
||||
if (!replacementActiveReplicator) {
|
||||
throw new Error("ReplicatorService did not activate a replacement P2P Replicator adapter");
|
||||
}
|
||||
if (replacementReplicator === firstReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous P2P replicator instance");
|
||||
if (replacementActiveReplicator === initialActiveReplicator) {
|
||||
throw new Error("Database reinitialisation retained the previous active P2P Replicator adapter");
|
||||
}
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("The previous P2P replicator remained open after replacement");
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("Database reinitialisation replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
if (p2pService.transportLifecycle.isConnected) {
|
||||
throw new Error("Database reinitialisation left the database-bound P2P room open");
|
||||
}
|
||||
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.P2P_AutoStart = true;
|
||||
await core.services.control.applySettings();
|
||||
const resumedReplicator = p2pReplicator.replicator;
|
||||
await waitForServing(resumedReplicator, timeoutMs);
|
||||
if (firstReplicator.server !== undefined) {
|
||||
throw new Error("A setting event reopened the previous P2P replicator");
|
||||
await waitForServing(p2pService, timeoutMs);
|
||||
if (p2pReplicator.replicator !== compatibilityFacade) {
|
||||
throw new Error("A setting event replaced the stable P2P service compatibility facade");
|
||||
}
|
||||
|
||||
const encoded = new TextEncoder().encode(noteContent);
|
||||
@@ -118,7 +127,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
});
|
||||
await core.serviceModules.fileHandler.storeFileToDB(notePath as FilePathWithPrefix, true);
|
||||
|
||||
const replacementPeer = await communicateWithPeer(resumedReplicator, targetPeer, timeoutMs);
|
||||
const replacementPeer = await communicateWithPeer(p2pService, targetPeer, timeoutMs);
|
||||
if (replacementPeer.name !== firstPeer.name) {
|
||||
throw new Error(
|
||||
`The replacement replicator reached '${replacementPeer.name}' instead of the original peer '${firstPeer.name}'`
|
||||
@@ -126,7 +135,7 @@ export async function runP2PReplicatorReplacementProbe(
|
||||
}
|
||||
|
||||
core.services.context.standardIo.writeStdout(
|
||||
`[Probe] P2P replicator replaced, old transport stayed closed, and ${notePath} was sent through the replacement.\n`
|
||||
`[Probe] The active P2P adapter was replaced, the stable service reopened, and ${notePath} was sent through it.\n`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
|
||||
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
|
||||
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
|
||||
"test:remote-administration-exit-codes": "deno test -A --no-check test-remote-administration-exit-codes.ts",
|
||||
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||
|
||||
@@ -143,8 +143,12 @@ export async function createCompressionBenchmarkDataset(options: {
|
||||
);
|
||||
await copyRepositoryFile("json", "package.json", "package.json");
|
||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ModuleReplicator.ts", "ModuleReplicator.ts");
|
||||
await copyRepositoryFile("ts", "src/modules/core/ReplicateResultProcessor.ts", "ReplicateResultProcessor.ts");
|
||||
await copyRepositoryFile("ts", "src/serviceFeatures/replication/index.ts", "replicationFeature.ts");
|
||||
await copyRepositoryFile(
|
||||
"ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
"ReplicateResultProcessor.ts"
|
||||
);
|
||||
|
||||
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:remote-administration-exit-codes",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
@@ -79,7 +79,6 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||
const backendPort = getFreePort();
|
||||
const proxyPort = getFreePort();
|
||||
@@ -156,8 +155,8 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||
"package.json",
|
||||
"manifest.json",
|
||||
"src/modules/core/ModuleReplicator.ts",
|
||||
"src/modules/core/ReplicateResultProcessor.ts",
|
||||
"src/serviceFeatures/replication/index.ts",
|
||||
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||
];
|
||||
try {
|
||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||
|
||||
@@ -39,7 +39,7 @@ async function runReplacementProbe(
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("p2p lifecycle: replacement keeps real CLI communication on the current replicator", async () => {
|
||||
Deno.test("p2p lifecycle: active-adapter replacement keeps real CLI communication on the stable service", async () => {
|
||||
const relay = Deno.env.get("RELAY") ?? "ws://localhost:4000/";
|
||||
const peersTimeout = Number(Deno.env.get("PEERS_TIMEOUT") ?? "20");
|
||||
const syncTimeout = Number(Deno.env.get("SYNC_TIMEOUT") ?? "60");
|
||||
@@ -82,11 +82,8 @@ Deno.test("p2p lifecycle: replacement keeps real CLI communication on the curren
|
||||
try {
|
||||
await host.waitUntilContains("P2P host is running", 20000);
|
||||
const probe = await runReplacementProbe(probeVault, probeSettings, hostPeerName, probeTimeoutMs);
|
||||
assert(
|
||||
probe.code === 0,
|
||||
`P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`
|
||||
);
|
||||
assertStringIncludes(probe.stdout, "[Probe] P2P replicator replaced");
|
||||
assert(probe.code === 0, `P2P replacement probe failed\nstdout: ${probe.stdout}\nstderr: ${probe.stderr}`);
|
||||
assertStringIncludes(probe.stdout, "[Probe] The active P2P adapter was replaced");
|
||||
|
||||
const syncResult = await runCli(
|
||||
verifierVault,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { applyCouchdbSettings, applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await Deno.mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
Deno.test("remote administration process exit policy distinguishes returned verification failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-exit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyP2pSettings(
|
||||
settingsFile,
|
||||
"remote-admin-exit-room",
|
||||
"remote-admin-exit-passphrase",
|
||||
"remote-admin-exit-tests",
|
||||
"ws://127.0.0.1:1/",
|
||||
"~.*",
|
||||
"none"
|
||||
);
|
||||
await applyP2pTestTweaks(settingsFile, "remote-admin-exit-device", "remote-admin-exit-passphrase");
|
||||
|
||||
const defaultFailure = await runCli(databaseDir, "--settings", settingsFile, "mark-resolved");
|
||||
assertEquals(defaultFailure.code, 1, defaultFailure.combined);
|
||||
assertStringIncludes(
|
||||
defaultFailure.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(defaultFailure.combined, "[Error] Command 'mark-resolved' failed");
|
||||
|
||||
const compatibilitySuccess = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(compatibilitySuccess.code, 0, compatibilitySuccess.combined);
|
||||
assertStringIncludes(
|
||||
compatibilitySuccess.combined,
|
||||
"[Verification] Remote administration is unavailable for this provider."
|
||||
);
|
||||
assertStringIncludes(compatibilitySuccess.combined, "[Done] Command 'mark-resolved' completed");
|
||||
});
|
||||
|
||||
Deno.test("remote administration compatibility does not hide a thrown mutation failure", async () => {
|
||||
const fixture = await prepareFixture("livesync-cli-remote-admin-mutation");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
await applyCouchdbSettings(
|
||||
settingsFile,
|
||||
"http://127.0.0.1:1/",
|
||||
"unreachable-user",
|
||||
"unreachable-password",
|
||||
"unreachable-database"
|
||||
);
|
||||
|
||||
const mutationFailure = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"--compat-remote-admin-exit-zero",
|
||||
"mark-resolved"
|
||||
);
|
||||
assertEquals(mutationFailure.code, 1, mutationFailure.combined);
|
||||
assertStringIncludes(mutationFailure.combined, "[Command] mark-resolved");
|
||||
assertStringIncludes(mutationFailure.combined, "[Error] Failed to start:");
|
||||
});
|
||||
@@ -146,6 +146,13 @@ export class WebAppRuntime {
|
||||
return this.paneHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import local files and complete the readiness boundary needed by optional P2P.
|
||||
*
|
||||
* An unconfigured central remote cannot use the normal offline-scan path, so
|
||||
* the explicit WebApp scan completes the same post-scan finalisation without
|
||||
* treating the central remote as configured.
|
||||
*/
|
||||
async scanLocalFiles(): Promise<boolean> {
|
||||
const core = this.core;
|
||||
const fileAccess = this.platformServiceModules?.vaultAccess;
|
||||
@@ -171,7 +178,17 @@ export class WebAppRuntime {
|
||||
this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan");
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
if (!succeeded || core.services.appLifecycle.isReady()) {
|
||||
return succeeded;
|
||||
}
|
||||
if (!(await core.services.databaseEvents.onDatabaseInitialised(false))) {
|
||||
return false;
|
||||
}
|
||||
if (!(await core.services.fileProcessing.commitPendingFileEvents())) {
|
||||
return false;
|
||||
}
|
||||
core.services.appLifecycle.markIsReady();
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.16-webapp",
|
||||
"version": "1.0.24-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -102,6 +102,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
UI: services.UI,
|
||||
vault: services.vault,
|
||||
fileHandler: fileHandler,
|
||||
fileProcessing: services.fileProcessing,
|
||||
storageAccess: storageAccess,
|
||||
control: services.control,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.16-webpeer",
|
||||
"version": "1.0.24-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -53,7 +53,7 @@ export class P2PCheckSession {
|
||||
|
||||
try {
|
||||
await runtime.start();
|
||||
await runtime.currentReplicator.makeSureOpened();
|
||||
await runtime.p2p.transportLifecycle.connect();
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
throw error;
|
||||
|
||||
@@ -3,10 +3,9 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
@@ -48,7 +47,7 @@ function removeFromList(item: string, list: string): string {
|
||||
export class WebPeerRuntime {
|
||||
readonly context: ServiceContext;
|
||||
readonly services: LiveSyncBrowserServiceHub<ServiceContext>;
|
||||
readonly p2p: UseP2PReplicatorResult;
|
||||
readonly p2p: P2PServiceViews;
|
||||
readonly p2pLogCollector: P2PLogCollector;
|
||||
readonly paneHost: P2PReplicatorPaneHost;
|
||||
|
||||
@@ -87,10 +86,6 @@ export class WebPeerRuntime {
|
||||
return this.context.events;
|
||||
}
|
||||
|
||||
get currentReplicator(): LiveSyncTrysteroReplicator {
|
||||
return this.p2p.replicator;
|
||||
}
|
||||
|
||||
get settings(): P2PSyncSetting {
|
||||
return this.services.setting.currentSettings();
|
||||
}
|
||||
@@ -119,9 +114,7 @@ export class WebPeerRuntime {
|
||||
}
|
||||
this.services.appLifecycle.markIsReady();
|
||||
this.events.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100);
|
||||
}
|
||||
await this.services.appLifecycle.onResumed();
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -151,12 +144,12 @@ export class WebPeerRuntime {
|
||||
this.menu = new Menu()
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.currentReplicator.replicateFrom(peer.peerId);
|
||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📤 Only send").onClick(async () => {
|
||||
await this.currentReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
await this.p2p.targetedTransfer.requestPushToPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addSeparator()
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
MILESTONE_DOCID,
|
||||
type EntryMilestoneInfo,
|
||||
type RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
applyCentralRemoteAdministrationMutation,
|
||||
milestoneSatisfiesCentralRemoteAdministration,
|
||||
centralRemoteAdministrationVerificationFailed,
|
||||
centralRemoteAdministrationVerified,
|
||||
supportedCapability,
|
||||
type MilestoneCentralRemoteAdministrationObservation,
|
||||
type CentralRemoteAdministrationFailureReason,
|
||||
type CentralRemoteAdministrationRequest,
|
||||
type CentralRemoteAdministrationReplicator,
|
||||
type CentralRemoteAdministrationResult,
|
||||
type CentralRemoteAdministrationRunner,
|
||||
type ReplicatorInstance,
|
||||
type SupportedCapability,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
/**
|
||||
* Central milestone administration shared by the two central providers.
|
||||
*
|
||||
* The provider definition selects a reader before mutation. CouchDB then owns
|
||||
* a fresh verification connection, while Object Storage borrows the active
|
||||
* Journal client. Local node identity is established before either mutation.
|
||||
*/
|
||||
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
|
||||
|
||||
/** A provider read result, including failures which settled without a throw. */
|
||||
type CentralMilestoneReadResult =
|
||||
| { readonly milestone: EntryMilestoneInfo | false | undefined }
|
||||
| { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown };
|
||||
|
||||
/** A settings-bound postcondition reader prepared before remote mutation. */
|
||||
type PreparedCentralMilestoneReader = () => Promise<CentralMilestoneReadResult>;
|
||||
|
||||
/** Select and validate the provider-specific reader without performing I/O. */
|
||||
type CentralMilestoneReaderPreparer = (
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings
|
||||
) => PreparedCentralMilestoneReader;
|
||||
|
||||
type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator &
|
||||
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting" | "isMobile">;
|
||||
|
||||
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJsonWithResult">;
|
||||
|
||||
function isCentralRemoteAdministrationReplicator(
|
||||
replicator: ReplicatorInstance
|
||||
): replicator is CentralRemoteAdministrationReplicator {
|
||||
return (
|
||||
"nodeid" in replicator &&
|
||||
typeof replicator.nodeid === "string" &&
|
||||
"markRemoteResolved" in replicator &&
|
||||
typeof replicator.markRemoteResolved === "function" &&
|
||||
"markRemoteLocked" in replicator &&
|
||||
typeof replicator.markRemoteLocked === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureLocalNodeIdentity(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): Promise<CentralRemoteAdministrationResult | undefined> {
|
||||
if (replicator.nodeid) {
|
||||
return undefined;
|
||||
}
|
||||
if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) {
|
||||
return undefined;
|
||||
}
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
function observeMilestone(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
milestone: EntryMilestoneInfo
|
||||
): MilestoneCentralRemoteAdministrationObservation {
|
||||
return {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: !!milestone.locked,
|
||||
accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid),
|
||||
nodeId: replicator.nodeid,
|
||||
};
|
||||
}
|
||||
|
||||
function resultFromMilestone(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
request: CentralRemoteAdministrationRequest,
|
||||
milestone: EntryMilestoneInfo | false | undefined
|
||||
): CentralRemoteAdministrationResult {
|
||||
if (!milestone) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND
|
||||
);
|
||||
}
|
||||
const observation = observeMilestone(replicator, milestone);
|
||||
return milestoneSatisfiesCentralRemoteAdministration(request.action, observation)
|
||||
? centralRemoteAdministrationVerified(observation)
|
||||
: centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
{
|
||||
observation,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply and verify the central milestone protocol without selecting a provider.
|
||||
*
|
||||
* The provider definition has already selected the reader preparer. Preparing
|
||||
* it before mutation rejects incomplete composition before a remote write and
|
||||
* binds any provider-owned client which must be used for postcondition reading.
|
||||
*/
|
||||
async function runCentralRemoteAdministration(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings,
|
||||
request: CentralRemoteAdministrationRequest,
|
||||
prepareMilestoneReader: CentralMilestoneReaderPreparer
|
||||
): Promise<CentralRemoteAdministrationResult> {
|
||||
const identityFailure = await ensureLocalNodeIdentity(replicator);
|
||||
if (identityFailure) return identityFailure;
|
||||
|
||||
const readMilestone = prepareMilestoneReader(replicator, setting);
|
||||
await applyCentralRemoteAdministrationMutation(replicator, setting, request.action);
|
||||
|
||||
const readResult = await readMilestone();
|
||||
if ("failureReason" in readResult) {
|
||||
return centralRemoteAdministrationVerificationFailed(readResult.failureReason, { detail: readResult.detail });
|
||||
}
|
||||
return resultFromMilestone(replicator, request, readResult.milestone);
|
||||
}
|
||||
|
||||
function requireCouchDBAdministrationOperations(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): asserts replicator is CouchDBAdministrationReplicator {
|
||||
if (
|
||||
!("connectRemoteCouchDBWithSetting" in replicator) ||
|
||||
typeof replicator.connectRemoteCouchDBWithSetting !== "function" ||
|
||||
!("isMobile" in replicator) ||
|
||||
typeof replicator.isMobile !== "function"
|
||||
) {
|
||||
throw new Error("The configured CouchDB administration adapter does not provide milestone access.");
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCouchDBMilestoneReader(
|
||||
replicator: CentralRemoteAdministrationReplicator,
|
||||
setting: RemoteDBSettings
|
||||
): PreparedCentralMilestoneReader {
|
||||
requireCouchDBAdministrationOperations(replicator);
|
||||
|
||||
return async () => {
|
||||
// This verification connection is fresh and owned by this read. It is
|
||||
// always closed here rather than retained by the active Replicator.
|
||||
let connection: Awaited<ReturnType<CouchDBAdministrationReplicator["connectRemoteCouchDBWithSetting"]>>;
|
||||
try {
|
||||
connection = await replicator.connectRemoteCouchDBWithSetting(setting, replicator.isMobile(), true);
|
||||
} catch (error) {
|
||||
return { failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, detail: error };
|
||||
}
|
||||
if (typeof connection === "string") {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||
detail: connection,
|
||||
};
|
||||
}
|
||||
|
||||
let milestone: EntryMilestoneInfo | undefined;
|
||||
let observationError: unknown;
|
||||
try {
|
||||
milestone = await connection.db.get<EntryMilestoneInfo>(MILESTONE_DOCID);
|
||||
} catch (error) {
|
||||
observationError = error;
|
||||
}
|
||||
try {
|
||||
await connection.close();
|
||||
} catch (error) {
|
||||
observationError ??= error;
|
||||
}
|
||||
if (observationError !== undefined) {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: observationError,
|
||||
};
|
||||
}
|
||||
return { milestone };
|
||||
};
|
||||
}
|
||||
|
||||
function isJournalAdministrationClient(client: unknown): client is JournalAdministrationClient {
|
||||
return (
|
||||
typeof client === "object" &&
|
||||
client !== null &&
|
||||
"downloadJsonWithResult" in client &&
|
||||
typeof client.downloadJsonWithResult === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function requireJournalAdministrationClient(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): JournalAdministrationClient {
|
||||
if (!("client" in replicator) || !isJournalAdministrationClient(replicator.client)) {
|
||||
throw new Error("The configured Object Storage administration adapter does not provide milestone access.");
|
||||
}
|
||||
return replicator.client;
|
||||
}
|
||||
|
||||
function assertNeverJournalStorageRead(result: never): never {
|
||||
throw new Error(`Unexpected Journal storage read result: ${String(result)}`);
|
||||
}
|
||||
|
||||
function prepareObjectStorageMilestoneReader(
|
||||
replicator: CentralRemoteAdministrationReplicator
|
||||
): PreparedCentralMilestoneReader {
|
||||
// The Journal client belongs to the active Replicator. This reader borrows
|
||||
// it for the provider's distinct milestone path and must not dispose it.
|
||||
const client = requireJournalAdministrationClient(replicator);
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const result = await client.downloadJsonWithResult<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH);
|
||||
switch (result.status) {
|
||||
case "available":
|
||||
return { milestone: result.value };
|
||||
case "not-found":
|
||||
return { milestone: undefined };
|
||||
case "unavailable":
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: result.error,
|
||||
};
|
||||
default:
|
||||
return assertNeverJournalStorageRead(result);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: error,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const runCouchDBCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||
replicator,
|
||||
setting,
|
||||
request
|
||||
) => {
|
||||
if (!isCentralRemoteAdministrationReplicator(replicator)) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE
|
||||
);
|
||||
}
|
||||
return await runCentralRemoteAdministration(replicator, setting, request, prepareCouchDBMilestoneReader);
|
||||
};
|
||||
|
||||
const runObjectStorageCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||
replicator,
|
||||
setting,
|
||||
request
|
||||
) => {
|
||||
if (!isCentralRemoteAdministrationReplicator(replicator)) {
|
||||
return centralRemoteAdministrationVerificationFailed(
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE
|
||||
);
|
||||
}
|
||||
return await runCentralRemoteAdministration(replicator, setting, request, prepareObjectStorageMilestoneReader);
|
||||
};
|
||||
|
||||
/** CouchDB mutation and milestone postcondition verification capability. */
|
||||
export const COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||
supportedCapability(runCouchDBCentralRemoteAdministration);
|
||||
|
||||
/** Object Storage mutation and milestone postcondition verification capability. */
|
||||
export const OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||
supportedCapability(runObjectStorageCentralRemoteAdministration);
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
describe("central remote administration capabilities", () => {
|
||||
it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => {
|
||||
const rawDatabaseClose = vi.fn(async () => undefined);
|
||||
const close = vi.fn(async () => undefined);
|
||||
const database = {
|
||||
get: vi.fn(async () => ({ locked: true, accepted_nodes: ["node-1"] })),
|
||||
close: rawDatabaseClose,
|
||||
};
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })),
|
||||
};
|
||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB };
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(replicator as never, setting, { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK })
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: true,
|
||||
accepted: true,
|
||||
nodeId: "node-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(replicator.markRemoteLocked).toHaveBeenCalledWith(setting, true, false);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(rawDatabaseClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a typed CouchDB failure when the observed milestone does not satisfy the action", async () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({
|
||||
db: { get: vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] })) },
|
||||
close,
|
||||
})),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
const result = await capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||
observation: { kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false },
|
||||
});
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mutate when initialisation succeeds without publishing a local node identity", async () => {
|
||||
const replicator = {
|
||||
nodeid: "",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE,
|
||||
});
|
||||
expect(replicator.markRemoteResolved).not.toHaveBeenCalled();
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a CouchDB mutation exception to reject before verification", async () => {
|
||||
const failure = new Error("write failed");
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
isMobile: vi.fn(() => false),
|
||||
markRemoteLocked: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(),
|
||||
};
|
||||
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mutates Object Storage and verifies its milestone postcondition", async () => {
|
||||
const milestone = { locked: false, accepted_nodes: ["node-1"] };
|
||||
const downloadJson = vi.fn(async () => milestone);
|
||||
const downloadJsonWithResult = vi.fn(async () => ({
|
||||
status: "available" as const,
|
||||
value: milestone,
|
||||
}));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson, downloadJsonWithResult },
|
||||
};
|
||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO };
|
||||
const capability = OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||
|
||||
await expect(
|
||||
capability.run(replicator as never, setting, {
|
||||
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||
observation: {
|
||||
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||
locked: false,
|
||||
accepted: true,
|
||||
nodeId: "node-1",
|
||||
},
|
||||
});
|
||||
expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting);
|
||||
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
expect(downloadJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a missing Object Storage milestone as an unverified postcondition", async () => {
|
||||
const downloadJson = vi.fn(async () => false);
|
||||
const downloadJsonWithResult = vi.fn(async () => ({ status: "not-found" as const }));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson, downloadJsonWithResult },
|
||||
};
|
||||
|
||||
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND,
|
||||
});
|
||||
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
expect(downloadJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a typed failure with diagnostic detail when Object Storage milestone reading is unavailable", async () => {
|
||||
const diagnostic = new Error("object storage unavailable");
|
||||
const downloadJson = vi.fn(async () => false);
|
||||
const downloadJsonWithResult = vi.fn(async () => ({
|
||||
status: "unavailable" as const,
|
||||
error: diagnostic,
|
||||
}));
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
client: { downloadJson, downloadJsonWithResult },
|
||||
};
|
||||
|
||||
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||
detail: diagnostic,
|
||||
});
|
||||
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||
expect(downloadJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an incomplete CouchDB milestone adapter before mutation", async () => {
|
||||
const markRemoteLocked = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked,
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK }
|
||||
)
|
||||
).rejects.toThrow("The configured CouchDB administration adapter does not provide milestone access.");
|
||||
expect(markRemoteLocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an Object Storage adapter which only exposes lossy milestone reading", async () => {
|
||||
const markRemoteResolved = vi.fn(async () => undefined);
|
||||
const replicator = {
|
||||
nodeid: "node-1",
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
markRemoteLocked: vi.fn(async () => undefined),
|
||||
markRemoteResolved,
|
||||
client: { downloadJson: vi.fn(async () => false) },
|
||||
};
|
||||
|
||||
await expect(
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||
replicator as never,
|
||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||
)
|
||||
).rejects.toThrow("The configured Object Storage administration adapter does not provide milestone access.");
|
||||
expect(markRemoteResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
type LegacyLocalDatabaseSelection = {
|
||||
useIndexedDBAdapter: boolean;
|
||||
};
|
||||
|
||||
type LegacyBulkChunkPreSendSettings = {
|
||||
sendChunksBulk: boolean;
|
||||
sendChunksBulkMaxSize: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether persisted settings select the legacy PouchDB IndexedDB adapter.
|
||||
*
|
||||
* New local databases use IDB. Existing devices must retain this operative value until their local database has
|
||||
* been explicitly migrated, so compatibility code must not treat the setting as inert.
|
||||
*/
|
||||
export function usesLegacyIndexedDBAdapter(settings: LegacyLocalDatabaseSelection): boolean {
|
||||
return settings.useIndexedDBAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the removed automatic bulk chunk pre-send option in persisted settings.
|
||||
*
|
||||
* The field remains readable only so older settings and Setup URIs can be migrated to the supported behaviour.
|
||||
*
|
||||
* @returns `true` when the legacy setting was enabled and has been changed.
|
||||
*/
|
||||
export function disableLegacyBulkChunkPreSend(settings: LegacyBulkChunkPreSendSettings): boolean {
|
||||
if (!settings.sendChunksBulk) return false;
|
||||
settings.sendChunksBulk = false;
|
||||
settings.sendChunksBulkMaxSize = 1;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { disableLegacyBulkChunkPreSend, usesLegacyIndexedDBAdapter } from "./compatibilitySettings.ts";
|
||||
|
||||
describe("compatibility settings", () => {
|
||||
it.each([true, false])("preserves the operative legacy adapter selection (%s)", (useIndexedDBAdapter) => {
|
||||
expect(usesLegacyIndexedDBAdapter({ useIndexedDBAdapter })).toBe(useIndexedDBAdapter);
|
||||
});
|
||||
|
||||
it("disables automatic bulk chunk pre-send and restores its inert size value", () => {
|
||||
const settings = { sendChunksBulk: true, sendChunksBulkMaxSize: 16 };
|
||||
|
||||
expect(disableLegacyBulkChunkPreSend(settings)).toBe(true);
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 1 });
|
||||
});
|
||||
|
||||
it("leaves an already migrated bulk chunk setting unchanged", () => {
|
||||
const settings = { sendChunksBulk: false, sendChunksBulkMaxSize: 4 };
|
||||
|
||||
expect(disableLegacyBulkChunkPreSend(settings)).toBe(false);
|
||||
expect(settings).toEqual({ sendChunksBulk: false, sendChunksBulkMaxSize: 4 });
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,7 @@ describe("packaged Commonlib compatibility gate", () => {
|
||||
databaseService: {},
|
||||
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
|
||||
replicatorService: {
|
||||
acquireActiveReplicatorContext: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveReplicator: () => ({ openReplication }),
|
||||
runFiniteReplicationActivity,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ export const EVENT_FILE_SAVED = "file-saved";
|
||||
export const EVENT_LEAF_ACTIVE_CHANGED = "leaf-active-changed";
|
||||
|
||||
export const EVENT_REQUEST_OPEN_SETTINGS = "request-open-settings";
|
||||
export const EVENT_REQUEST_OPEN_SETTING_WIZARD = "request-open-setting-wizard";
|
||||
export const EVENT_REQUEST_OPEN_SETUP_URI = "request-open-setup-uri";
|
||||
export const EVENT_REQUEST_COPY_SETUP_URI = "request-copy-setup-uri";
|
||||
export const EVENT_REQUEST_SHOW_SETUP_QR = "request-show-setup-qr";
|
||||
@@ -30,7 +29,6 @@ declare global {
|
||||
[EVENT_PLUGIN_UNLOADED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTINGS]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETTING_WIZARD]: undefined;
|
||||
[EVENT_REQUEST_RELOAD_SETTING_TAB]: undefined;
|
||||
[EVENT_LEAF_ACTIVE_CHANGED]: undefined;
|
||||
[EVENT_REQUEST_OPEN_SETUP_URI]: undefined;
|
||||
|
||||
@@ -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.",
|
||||
@@ -32,6 +30,20 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
|
||||
"Connection compatibility": "Connection compatibility",
|
||||
"P2P message size": "P2P message size",
|
||||
Standard: "Standard",
|
||||
Reduced: "Reduced",
|
||||
Conservative: "Conservative",
|
||||
"Maximum compatibility": "Maximum compatibility",
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.":
|
||||
"Smaller messages can improve compatibility on paths which fragment or drop larger WebRTC messages. This setting limits outgoing P2P messages, so use a compatible profile on each sending device when required.",
|
||||
"Connection path": "Connection path",
|
||||
"TURN relay only": "TURN relay only",
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.":
|
||||
"TURN relay only is available when at least one valid TURN server URL is configured under Advanced Settings.",
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.":
|
||||
"TURN relay only requires at least one valid TURN server URL. Connection path has been restored to Automatic.",
|
||||
"Announce changes": "Announce changes",
|
||||
"Announce changes automatically after connecting": "Announce changes automatically after connecting",
|
||||
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.":
|
||||
@@ -45,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",
|
||||
@@ -61,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})",
|
||||
@@ -4206,16 +4223,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "显示日志",
|
||||
"zh-tw": "顯示日誌",
|
||||
},
|
||||
"moduleMigration.docUri": {
|
||||
def: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
es: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use",
|
||||
fr: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
he: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
ja: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
ko: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
ru: "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
zh: "https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/zh/README_zh.md#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8",
|
||||
},
|
||||
"moduleMigration.fix0256.buttons.checkItLater": {
|
||||
def: "Check it later",
|
||||
es: "Comprobarlo más tarde",
|
||||
@@ -4451,30 +4458,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
"zh-tw":
|
||||
"你可能已經知道,Self-hosted LiveSync 變更了預設行為與資料庫結構。\n\n值得慶幸的是,在你的努力下,遠端資料庫似乎已經完成遷移。恭喜!\n\n不過,還需要再多做一點。此裝置的設定與遠端資料庫不相容,我們需要再次從遠端資料庫抓取。現在要再次從遠端抓取嗎?\n\n___注意:在變更設定並再次抓取資料庫之前,我們無法進行同步。___\n___注意 2:chunks 完全不可變,我們只需要抓取中繼資料與差異部分。___",
|
||||
},
|
||||
"moduleMigration.msgInitialSetup": {
|
||||
def: "Your device has **not been set up yet**. Let me guide you through the setup process.\n\nPlease keep in mind that every dialogue content can be copied to the clipboard. If you need to refer to it later, you can paste it into a note in Obsidian. You can also translate it into your language using a translation tool.\n\nFirst, do you have **Setup URI**?\n\nNote: If you do not know what it is, please refer to the [documentation](${URI_DOC}).",
|
||||
es: "Tu dispositivo **aún no ha sido configurado**. Permíteme guiarte a través del proceso de configuración.\n\nTen en cuenta que todo el contenido del diálogo se puede copiar al portapapeles. Si necesitas consultarlo más tarde, puedes pegarlo en una nota en Obsidian. También puedes traducirlo a tu idioma utilizando una herramienta de traducción.\n\nPrimero, ¿tienes **URI de configuración**?\n\nNota: Si no sabes qué es, consulta la [documentación](${URI_DOC}).",
|
||||
fr: "Votre appareil n'a **pas encore été configuré**. Laissez-moi vous guider dans le processus de configuration.\n\nVeuillez noter que chaque contenu de boîte de dialogue peut être copié dans le presse-papiers. Si vous souhaitez vous y référer plus tard, vous pouvez le coller dans une note d'Obsidian. Vous pouvez également le traduire dans votre langue via un outil de traduction.\n\nTout d'abord, disposez-vous d'une **URI de configuration** ?\n\nNote : Si vous ne savez pas ce que c'est, consultez la [documentation](${URI_DOC}).",
|
||||
he: "המכשיר שלך **טרם הוגדר**. אנחנו כאן לעזור לך בתהליך ההגדרה.\n\nשים לב שניתן להעתיק את תוכן כל דיאלוג ללוח. אם צריך לחזור אליו מאוחר יותר, ניתן להדביק אותו כפתק ב-Obsidian. ניתן גם לתרגם לשפתך בעזרת כלי תרגום.\n\nראשית, האם יש לך **Setup URI**?\n\nהערה: אם אינך יודע מהו, אנא עיין ב[תיעוד](${URI_DOC}).",
|
||||
ja: "このデバイスは**まだセットアップされていません**。セットアッププロセスをご案内します。\n\nすべてのダイアログの内容はクリップボードにコピーできます。後で参照する必要があれば、Obsidianのノートに貼り付けてください。翻訳ツールを使ってお使いの言語に翻訳することもできます。\n\nまず、**セットアップURI**をお持ちですか?\n\n注意: それが何か分からない場合は、[documentation](${URI_DOC})を参照してください。",
|
||||
ko: "이 기기는 **아직 초기 설정이 완료되지 않았습니다**. 지금부터 설정 과정을 안내해 드리겠습니다.\n\n모든 대화 내용은 클립보드에 복사할 수 있습니다. 나중에 참고하려면 Obsidian 노트에 붙여넣거나 번역 도구를 활용해 번역하셔도 됩니다.\n\n먼저, **Setup URI**를 가지고 계신가요?\n\n참고: Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요.",
|
||||
ru: "Ваше устройство ещё не настроено. У вас есть Setup URI?",
|
||||
zh: "您的设备**尚未设置**。让我引导您完成设置过程。\n\n请记住,每个对话框内容都可以复制到剪贴板。如果以后需要参考,可以将其粘贴到 Obsidian 的笔记中。您也可以使用翻译工具将其翻译成您的语言。\n\n首先,您有**设置 URI** 吗?\n\n注意:如果您不知道这是什么,请参阅[文档](${URI_DOC})",
|
||||
"zh-tw":
|
||||
"你的裝置**尚未完成設定**。讓我引導你完成設定流程。\n\n請記得,每個對話框的內容都可以複製到剪貼簿。如果日後需要參考,可以貼到 Obsidian 的筆記中,也可以用翻譯工具翻成你的語言。\n\n首先,你有 **Setup URI** 嗎?\n\n注意:如果你不知道那是什麼,請參閱[說明文件](${URI_DOC})。",
|
||||
},
|
||||
"moduleMigration.msgRecommendSetupUri": {
|
||||
def: "We strongly recommend that you generate a set-up URI and use it.\nIf you do not have knowledge about it, please refer to the [documentation](${URI_DOC}) (Sorry again, but it is important).\n\nHow do you want to set it up manually?",
|
||||
es: "Te recomendamos encarecidamente que generes una URI de configuración y la utilices.\nSi no tienes conocimientos al respecto, consulta la [documentación](${URI_DOC}) (Lo siento de nuevo, pero es importante).\n\n¿Cómo quieres configurarlo manualmente?",
|
||||
fr: "Nous recommandons vivement de générer une URI de configuration et de l'utiliser.\nSi vous ne connaissez pas, veuillez consulter la [documentation](${URI_DOC}) (Désolé encore, mais c'est important).\n\nComment souhaitez-vous effectuer la configuration manuellement ?",
|
||||
he: "אנו ממליצים בחום לייצר Setup URI ולהשתמש בו.\nאם אין לך ידע בנושא, אנא עיין ב[תיעוד](${URI_DOC}) (מתנצלים שוב, אך זה חשוב).\n\nכיצד ברצונך להגדיר ידנית?",
|
||||
ja: "セットアップURIを生成して使用することを強くお勧めします。\nこれについて知識がない場合は、[documentation](${URI_DOC})を参照してください(重要です)。\n\n手動でセットアップしますか?",
|
||||
ko: "Setup URI를 생성해 사용하는 것을 강력히 권장합니다.\nSetup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요. 중요한 내용이니 꼭 확인하시기 바랍니다.\n\n직접 수동 설정을 진행하시겠습니까?",
|
||||
ru: "Мы рекомендуем сгенерировать Setup URI.",
|
||||
zh: "我们强烈建议您生成一个设置 URI 并使用它。\n如果您对此不了解,请参阅[文档](${URI_DOC})(再次抱歉,但这很重要)。\n\n您想如何手动设置?",
|
||||
"zh-tw":
|
||||
"我們強烈建議你產生 Setup URI 並使用它。\n如果你對此不熟悉,請參閱[說明文件](${URI_DOC})(再次抱歉,但這很重要)。\n\n你想要如何手動設定?",
|
||||
},
|
||||
"moduleMigration.msgSinceV02321": {
|
||||
def: "Since v0.23.21, the self-hosted LiveSync has changed the default behaviour and database structure. The following changes have been made:\n\n1. **Case sensitivity of filenames**\n The handling of filenames is now case-insensitive. This is a beneficial change for most platforms, other than Linux and iOS, which do not manage filename case sensitivity effectively.\n (On These, a warning will be displayed for files with the same name but different cases).\n\n2. **Revision handling of the chunks**\n Chunks are immutable, which allows their revisions to be fixed. This change will enhance the performance of file saving.\n\n___However, to enable either of these changes, both remote and local databases need to be rebuilt. This process takes a few minutes, and we recommend doing it when you have ample time.___\n\n- If you wish to maintain the previous behaviour, you can skip this process by using `${KEEP}`.\n- If you do not have enough time, please choose `${DISMISS}`. You will be prompted again later.\n- If you have rebuilt the database on another device, please select `${DISMISS}` and try synchronizing again. Since a difference has been detected, you will be prompted again.",
|
||||
es: "Desde la versión v0.23.21, Self-hosted LiveSync ha cambiado el comportamiento predeterminado y la estructura de la base de datos. Se han realizado los siguientes cambios:\n\n1. **Sensibilidad a mayúsculas de los nombres de archivo**\n El manejo de los nombres de archivo ahora no distingue entre mayúsculas y minúsculas. Este cambio es beneficioso para la mayoría de las plataformas, excepto Linux y iOS, que no gestionan efectivamente la sensibilidad a mayúsculas de los nombres de archivo.\n (En estos, se mostrará una advertencia para archivos con el mismo nombre pero diferentes mayúsculas).\n\n2. **Manejo de revisiones de los fragmentos**\n Los fragmentos son inmutables, lo que permite que sus revisiones sean fijas. Este cambio mejorará el rendimiento al guardar archivos.\n\n___Sin embargo, para habilitar cualquiera de estos cambios, es necesario reconstruir tanto las bases de datos remota como la local. Este proceso toma unos minutos, y recomendamos hacerlo cuando tengas tiempo suficiente.___\n\n- Si deseas mantener el comportamiento anterior, puedes omitir este proceso usando `${KEEP}`.\n- Si no tienes suficiente tiempo, por favor elige `${DISMISS}`. Se te pedirá nuevamente más tarde.\n- Si has reconstruido la base de datos en otro dispositivo, selecciona `${DISMISS}` e intenta sincronizar nuevamente. Dado que se ha detectado una diferencia, se te solicitará nuevamente.",
|
||||
@@ -4542,17 +4525,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "仅启用 #2",
|
||||
"zh-tw": "僅啟用",
|
||||
},
|
||||
"moduleMigration.optionHaveSetupUri": {
|
||||
def: "Yes, I have",
|
||||
es: "Sí, tengo",
|
||||
fr: "Oui, j'en ai une",
|
||||
he: "כן, יש לי",
|
||||
ja: "はい、持っています",
|
||||
ko: "예, 있습니다",
|
||||
ru: "Да, есть",
|
||||
zh: "是的,我有",
|
||||
"zh-tw": "有,我有",
|
||||
},
|
||||
"moduleMigration.optionKeepPreviousBehaviour": {
|
||||
def: "Keep previous behaviour",
|
||||
es: "Mantener comportamiento anterior",
|
||||
@@ -4564,17 +4536,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "保持以前的行为",
|
||||
"zh-tw": "保留先前的行為",
|
||||
},
|
||||
"moduleMigration.optionManualSetup": {
|
||||
def: "Set it up all manually",
|
||||
es: "Configurarlo todo manualmente",
|
||||
fr: "Tout configurer manuellement",
|
||||
he: "הגדר הכל ידנית",
|
||||
ja: "すべて手動でセットアップ",
|
||||
ko: "모든 것을 수동으로 설정",
|
||||
ru: "Настроить всё вручную",
|
||||
zh: "全部手动设置",
|
||||
"zh-tw": "全部手動設定",
|
||||
},
|
||||
"moduleMigration.optionNoAskAgain": {
|
||||
def: "No, please ask again",
|
||||
es: "No, por favor pregúntame de nuevo",
|
||||
@@ -4586,50 +4547,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "不,请稍后再次询问",
|
||||
"zh-tw": "不,請稍後再問我",
|
||||
},
|
||||
"moduleMigration.optionNoSetupUri": {
|
||||
def: "No, I do not have",
|
||||
es: "No, no tengo",
|
||||
fr: "Non, je n'en ai pas",
|
||||
he: "לא, אין לי",
|
||||
ja: "いいえ、持っていません",
|
||||
ko: "아니요, 없습니다",
|
||||
ru: "Нет, нет",
|
||||
zh: "不,我没有",
|
||||
"zh-tw": "沒有,我沒有",
|
||||
},
|
||||
"moduleMigration.optionRemindNextLaunch": {
|
||||
def: "Remind me at the next launch",
|
||||
es: "Recordármelo en el próximo inicio",
|
||||
fr: "Me rappeler au prochain lancement",
|
||||
he: "הזכר לי בהפעלה הבאה",
|
||||
ja: "次回起動時にリマインド",
|
||||
ko: "다음 시작 시 알림",
|
||||
ru: "Напомнить при следующем запуске",
|
||||
zh: "下次启动时提醒我",
|
||||
"zh-tw": "下次啟動時提醒我",
|
||||
},
|
||||
"moduleMigration.optionSetupViaP2P": {
|
||||
def: "Use P2P Sync to set up",
|
||||
es: "Usar Sincronización P2P para configurarlo",
|
||||
fr: "Utiliser Sync P2P pour configurer",
|
||||
he: "השתמש ב-%{short_p2p_sync} להגדרה",
|
||||
ja: "P2P Sync (試験機能)を使ってセットアップ",
|
||||
ko: "P2P 동기화를 사용하여 설정",
|
||||
ru: "Использовать short_p2p_sync для настройки",
|
||||
zh: "Use P2P同步(实验性) to set up",
|
||||
"zh-tw": "使用「P2P 同步」進行設定",
|
||||
},
|
||||
"moduleMigration.optionSetupWizard": {
|
||||
def: "Take me into the setup wizard",
|
||||
es: "Llévame al asistente de configuración",
|
||||
fr: "Ouvrir l'assistant de configuration",
|
||||
he: "קח אותי לאשף ההגדרה",
|
||||
ja: "セットアップウィザードへ",
|
||||
ko: "설정 마법사로 안내",
|
||||
ru: "Перейти в мастер настройки",
|
||||
zh: "带我进入设置向导",
|
||||
"zh-tw": "帶我進入設定精靈",
|
||||
},
|
||||
"moduleMigration.optionYesFetchAgain": {
|
||||
def: "Yes, fetch again",
|
||||
es: "Sí, obtener de nuevo",
|
||||
@@ -4652,28 +4569,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "大小写敏感性",
|
||||
"zh-tw": "大小寫敏感性",
|
||||
},
|
||||
"moduleMigration.titleRecommendSetupUri": {
|
||||
def: "Recommendation to use Setup URI",
|
||||
es: "Recomendación de usar un Setup URI",
|
||||
fr: "Recommandation d'utilisation de l'URI de configuration",
|
||||
he: "המלצה לשימוש ב-Setup URI",
|
||||
ja: "セットアップURIの使用を推奨",
|
||||
ko: "Setup URI 사용 권장",
|
||||
ru: "Рекомендация использовать Setup URI",
|
||||
zh: "推荐使用设置 URI",
|
||||
"zh-tw": "建議使用 Setup URI",
|
||||
},
|
||||
"moduleMigration.titleWelcome": {
|
||||
def: "Welcome to Self-hosted LiveSync",
|
||||
es: "Bienvenido a Self-hosted LiveSync",
|
||||
fr: "Bienvenue dans Self-hosted LiveSync",
|
||||
he: "ברוך הבא ל-Self-hosted LiveSync",
|
||||
ja: "Self-hosted LiveSyncへようこそ",
|
||||
ko: "Self-hosted LiveSync에 오신 것을 환영합니다",
|
||||
ru: "Добро пожаловать в Self-hosted LiveSync",
|
||||
zh: "欢迎使用 Self-hosted LiveSync",
|
||||
"zh-tw": "歡迎使用 Self-hosted LiveSync",
|
||||
},
|
||||
"moduleObsidianMenu.replicate": {
|
||||
def: "Replicate",
|
||||
es: "Replicar",
|
||||
@@ -5061,17 +4956,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "我明白了并且已更新",
|
||||
"zh-tw": "我知道了,且已更新。",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.btnNext": {
|
||||
def: "Next",
|
||||
es: "Siguiente",
|
||||
fr: "Suivant",
|
||||
he: "הבא",
|
||||
ja: "次へ",
|
||||
ko: "다음",
|
||||
ru: "Далее",
|
||||
zh: "下一步",
|
||||
"zh-tw": "下一步",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.btnStart": {
|
||||
def: "Start",
|
||||
es: "Iniciar",
|
||||
@@ -5116,17 +5000,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "获取",
|
||||
"zh-tw": "抓取",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.buttonNext": {
|
||||
def: "Next",
|
||||
es: "Siguiente",
|
||||
fr: "Suivant",
|
||||
he: "הבא",
|
||||
ja: "次へ",
|
||||
ko: "다음",
|
||||
ru: "Далее",
|
||||
zh: "下一步",
|
||||
"zh-tw": "下一步",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": {
|
||||
def: "Default",
|
||||
es: "Predeterminado",
|
||||
@@ -5513,7 +5386,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",
|
||||
@@ -5812,17 +5685,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "要从远端服务器获取配置吗?",
|
||||
"zh-tw": "要從遠端伺服器抓取設定嗎?",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": {
|
||||
def: "All done! Do you want to generate a setup URI to set up other devices?",
|
||||
es: "¡Todo listo! ¿Quieres generar un URI de configuración para configurar otros dispositivos?",
|
||||
fr: "Tout est prêt ! Voulez-vous générer une URI de configuration pour configurer d'autres appareils ?",
|
||||
he: "הכל מוכן! האם ברצונך לייצר Setup URI להגדרת מכשירים אחרים?",
|
||||
ja: "完了!他のデバイスをセットアップするためのセットアップURIを生成しますか?",
|
||||
ko: "모든 작업이 완료되었습니다! 다른 기기를 설정하기 위해 Setup URI를 생성하시겠습니까?",
|
||||
ru: "Всё готово! Вы хотите сгенерировать Setup URI для настройки других устройств?",
|
||||
zh: "全部完成!要生成设置 URI 以便配置其他设备吗?",
|
||||
"zh-tw": "全部完成!要產生 Setup URI 以便設定其他裝置嗎?",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": {
|
||||
def: "If the server configuration is not persistent (e.g., running on docker), the values here may change. Once you are able to connect, please update the settings in the server's local.ini.",
|
||||
es: "Si la configuración del servidor no es persistente (por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una vez que puedas conectarte, por favor actualiza las configuraciones en el local.ini del servidor.",
|
||||
@@ -5925,17 +5787,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
"zh-tw":
|
||||
"需要重建資料庫才能套用變更。請選擇套用變更的方式。\n\n<details>\n<summary>圖例</summary>\n\n| 符號 | 含意 |\n|: ------ :| ------- |\n| ⇔ | 已是最新 |\n| ⇄ | 同步以取得一致 |\n| ⇐,⇒ | 傳輸並覆寫 |\n| ⇠,⇢ | 從另一端傳輸並覆寫 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n概覽: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\n以此裝置上現有的檔案重建本機與遠端資料庫。\n這會鎖定其他裝置,它們必須執行抓取。\n## ${OPTION_FETCH}\n概覽: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\n初始化本機資料庫,並以從遠端資料庫抓取的資料重建。\n這也涵蓋你已重建遠端資料庫的情況。\n## ${OPTION_ONLY_SETTING}\n僅儲存設定。**注意:這可能導致資料損毀**;通常仍需要重建資料庫。",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": {
|
||||
def: "Please select and apply any preset item to complete the wizard.",
|
||||
es: "Por favor, selecciona y aplica cualquier elemento preestablecido para completar el asistente.",
|
||||
fr: "Veuillez sélectionner et appliquer un préréglage pour terminer l'assistant.",
|
||||
he: "אנא בחר והחל פריט קבוע מראש כלשהו להשלמת האשף.",
|
||||
ja: "ウィザードを完了するには、プリセット項目を選択して適用してください。",
|
||||
ko: "마법사를 완료하려면 프리셋 항목을 선택하고 적용해 주세요.",
|
||||
ru: "Выберите и примените любой пресет для завершения мастера.",
|
||||
zh: "请选择并应用任一预设项以完成向导。",
|
||||
"zh-tw": "請選擇並套用任一預設項目以完成精靈。",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": {
|
||||
def: "Set cors.credentials",
|
||||
es: "Configurar cors.credentials",
|
||||
@@ -6147,7 +5998,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: "データベース接続テスト",
|
||||
@@ -6158,7 +6009,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: "データベース設定を検証",
|
||||
@@ -6466,7 +6317,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: "一般設定",
|
||||
@@ -6477,7 +6328,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: "プライバシーと暗号化",
|
||||
@@ -6530,6 +6381,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "活动远程服务器",
|
||||
"zh-tw": "使用中的遠端伺服器",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleAdvancedSettings": {
|
||||
def: "Advanced settings",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": {
|
||||
def: "Appearance",
|
||||
es: "Apariencia",
|
||||
@@ -6552,17 +6406,6 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "冲突处理",
|
||||
"zh-tw": "衝突處理",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": {
|
||||
def: "Congratulations!",
|
||||
es: "¡Felicidades!",
|
||||
fr: "Félicitations !",
|
||||
he: "מזל טוב!",
|
||||
ja: "おめでとうございます!",
|
||||
ko: "축하합니다!",
|
||||
ru: "Поздравляем!",
|
||||
zh: "恭喜!",
|
||||
"zh-tw": "恭喜!",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": {
|
||||
def: "CouchDB",
|
||||
es: "Servidor CouchDB",
|
||||
@@ -6618,6 +6461,12 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "启用额外和高级功能",
|
||||
"zh-tw": "啟用額外與進階功能",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleExtraFeaturesGroup": {
|
||||
def: "Extra features",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleExtraMenus": {
|
||||
def: "Extra menus",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleFetchConfig": {
|
||||
def: "Fetch Config",
|
||||
es: "Obtener configuración",
|
||||
@@ -6651,6 +6500,12 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "获取设置",
|
||||
"zh-tw": "抓取設定",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleHelpAndInformation": {
|
||||
def: "Help and information",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting": {
|
||||
def: "Help and troubleshooting",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleHiddenFiles": {
|
||||
def: "Hidden Files",
|
||||
es: "Archivos ocultos",
|
||||
@@ -6673,6 +6528,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "日志",
|
||||
"zh-tw": "記錄",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleMaintenanceAndRecovery": {
|
||||
def: "Maintenance and recovery",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleMinioS3R2": {
|
||||
def: "Minio,S3,R2",
|
||||
es: "MinIO, S3, R2",
|
||||
@@ -6762,7 +6620,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
"zh-tw": "重設",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": {
|
||||
def: "To setup other devices",
|
||||
def: "Set up other devices",
|
||||
es: "Para configurar otros dispositivos",
|
||||
fr: "Pour configurer d'autres appareils",
|
||||
he: "להגדרת מכשירים אחרים",
|
||||
@@ -6772,6 +6630,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "设置其他设备",
|
||||
"zh-tw": "設定其他裝置",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleSynchronisation": {
|
||||
def: "Synchronisation",
|
||||
},
|
||||
"obsidianLiveSyncSettingTab.titleSynchronizationMethod": {
|
||||
def: "Synchronization Method",
|
||||
es: "Método de sincronización",
|
||||
@@ -6796,7 +6657,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: "同期設定",
|
||||
@@ -8187,7 +8048,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
"zh-tw": "複寫因某個模組失敗而取消",
|
||||
},
|
||||
"Replicator.Message.VersionUpFlash": {
|
||||
def: "An update has been detected. Please open the Settings dialogue and check the Change Log. Replication has been cancelled.",
|
||||
def: "Remote synchronisation is paused for compatibility review. Run the 'Review why synchronisation is paused' command for details and available actions.",
|
||||
es: "Se ha detectado una actualización. Abre el diálogo de ajustes y consulta el registro de cambios. La replicación se ha cancelado.",
|
||||
fr: "Une mise à jour a été détectée. Veuillez ouvrir la boîte de dialogue des paramètres et consulter le journal des modifications. La réplication a été annulée.",
|
||||
he: "זוהה עדכון. אנא פתח את דיאלוג ההגדרות ובדוק את יומן השינויים. השכפול בוטל.",
|
||||
@@ -8540,6 +8401,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",
|
||||
@@ -8551,6 +8416,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)",
|
||||
@@ -9570,6 +9440,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",
|
||||
@@ -9929,6 +9803,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",
|
||||
@@ -9947,6 +9825,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "仅供测试 - 通过同步文件的较新副本来解决文件冲突,这可能会覆盖修改过的文件。请注意 ",
|
||||
"zh-tw": "僅供測試 —— 透過同步較新的檔案版本解決衝突,這可能會覆寫已修改的檔案,請注意。",
|
||||
},
|
||||
"The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.":
|
||||
{
|
||||
def: "The connection test cannot add a signalling relay while P2P is active. Use the active relay settings, or disconnect P2P before testing.",
|
||||
},
|
||||
"The connection to the server has been configured successfully. As the next step,": {
|
||||
def: "The connection to the server has been configured successfully. As the next step,",
|
||||
es: "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
|
||||
@@ -10150,6 +10032,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。",
|
||||
@@ -10176,6 +10063,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",
|
||||
@@ -11871,6 +11763,72 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "覆盖",
|
||||
"zh-tw": "覆寫",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": {
|
||||
def: "Apply without Initialisation",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Back": {
|
||||
def: "Review another way to apply these settings",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": {
|
||||
def: "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": {
|
||||
def: "Apply Settings without Initialisation?",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": {
|
||||
def: "Continue with Fetch",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": {
|
||||
def: "Reset Synchronisation on This Device",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": {
|
||||
def: "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": {
|
||||
def: "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": {
|
||||
def: "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": {
|
||||
def: "Keep Editing",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": {
|
||||
def: "Restart and Fetch Synchronisation Data",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": {
|
||||
def: "Restart and Select a Source Device",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": {
|
||||
def: "Restart and Overwrite Server Data",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": {
|
||||
def: "Restart and Prepare This Device",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Question": {
|
||||
def: "Which existing data should be used after restart?",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": {
|
||||
def: "Overwrite Server Data with This Device's Files",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": {
|
||||
def: "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": {
|
||||
def: "Prepare This Device from This Vault",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": {
|
||||
def: "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": {
|
||||
def: "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": {
|
||||
def: "Remote Synchronisation Data Could Not Be Verified",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Title": {
|
||||
def: "Apply Settings and Reinitialise Synchronisation",
|
||||
},
|
||||
"Ui.SetupWizard.Common.Back": {
|
||||
def: "No, please take me back",
|
||||
es: "No, volver atrás",
|
||||
@@ -12770,6 +12728,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。",
|
||||
@@ -112,8 +112,6 @@
|
||||
"Obsidian version": "Obsidian-Version",
|
||||
"obsidianLiveSyncSettingTab.btnApply": "Anwenden",
|
||||
"obsidianLiveSyncSettingTab.btnDisable": "Deaktivieren",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Weiter",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Weiter",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Standardsprache",
|
||||
"obsidianLiveSyncSettingTab.labelDisabled": "⏹️ : Deaktiviert",
|
||||
"obsidianLiveSyncSettingTab.labelEnabled": "🔁 : Aktiviert",
|
||||
@@ -124,9 +122,7 @@
|
||||
"obsidianLiveSyncSettingTab.msgConfigCheckFailed": "Die Konfigurationsprüfung ist fehlgeschlagen. Trotzdem fortfahren?",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Wir empfehlen, Ende-zu-Ende-Verschlüsselung und Pfadverschleierung zu aktivieren. Möchten Sie wirklich ohne Verschlüsselung fortfahren?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Möchten Sie die Konfiguration vom Remote-Server abrufen?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "Alles fertig! Möchten Sie eine Setup-URI erzeugen, um andere Geräte einzurichten?",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Ihre Verschlüsselungs-Passphrase könnte ungültig sein. Möchten Sie wirklich fortfahren?",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Bitte wählen und übernehmen Sie eine beliebige Voreinstellung, um den Assistenten abzuschließen.",
|
||||
"obsidianLiveSyncSettingTab.nameDisableHiddenFileSync": "Synchronisation versteckter Dateien deaktivieren",
|
||||
"obsidianLiveSyncSettingTab.nameEnableHiddenFileSync": "Synchronisation versteckter Dateien aktivieren",
|
||||
"obsidianLiveSyncSettingTab.nameHiddenFileSynchronization": "Synchronisation versteckter Dateien",
|
||||
@@ -137,7 +133,6 @@
|
||||
"obsidianLiveSyncSettingTab.optionPeriodicWithBatch": "Periodisch mit Stapelverarbeitung",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Darstellung",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Konfliktbehandlung",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Glückwunsch!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB-Server",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Weitergabe von Löschungen",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Verschlüsselung ist nicht aktiviert",
|
||||
|
||||
@@ -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.",
|
||||
@@ -478,7 +484,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
|
||||
"moduleLog.showLog": "Show Log",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Check it later",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "I have fixed it, and do not ask again",
|
||||
"moduleMigration.fix0256.buttons.fix": "Fix",
|
||||
@@ -500,26 +505,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "Could not get remote tweak values",
|
||||
"moduleMigration.logSetupCancelled": "The setup has been cancelled, Self-hosted LiveSync waiting for your setup!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "As you may already know, the self-hosted LiveSync has changed its default behaviour and database structure.\n\nAnd thankfully, with your time and efforts, the remote database appears to have already been migrated. Congratulations!\n\nHowever, we need a bit more. The configuration of this device is not compatible with the remote database. We will need to fetch the remote database again. Should we fetch from the remote again now?\n\n___Note: We cannot synchronise until the configuration has been changed and the database has been fetched again.___\n___Note2: The chunks are completely immutable, we can fetch only the metadata and difference.___",
|
||||
"moduleMigration.msgInitialSetup": "Your device has **not been set up yet**. Let me guide you through the setup process.\n\nPlease keep in mind that every dialogue content can be copied to the clipboard. If you need to refer to it later, you can paste it into a note in Obsidian. You can also translate it into your language using a translation tool.\n\nFirst, do you have **Setup URI**?\n\nNote: If you do not know what it is, please refer to the [documentation](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "We strongly recommend that you generate a set-up URI and use it.\nIf you do not have knowledge about it, please refer to the [documentation](${URI_DOC}) (Sorry again, but it is important).\n\nHow do you want to set it up manually?",
|
||||
"moduleMigration.msgSinceV02321": "Since v0.23.21, the self-hosted LiveSync has changed the default behaviour and database structure. The following changes have been made:\n\n1. **Case sensitivity of filenames**\n The handling of filenames is now case-insensitive. This is a beneficial change for most platforms, other than Linux and iOS, which do not manage filename case sensitivity effectively.\n (On These, a warning will be displayed for files with the same name but different cases).\n\n2. **Revision handling of the chunks**\n Chunks are immutable, which allows their revisions to be fixed. This change will enhance the performance of file saving.\n\n___However, to enable either of these changes, both remote and local databases need to be rebuilt. This process takes a few minutes, and we recommend doing it when you have ample time.___\n\n- If you wish to maintain the previous behaviour, you can skip this process by using `${KEEP}`.\n- If you do not have enough time, please choose `${DISMISS}`. You will be prompted again later.\n- If you have rebuilt the database on another device, please select `${DISMISS}` and try synchronizing again. Since a difference has been detected, you will be prompted again.",
|
||||
"moduleMigration.optionAdjustRemote": "Adjust to remote",
|
||||
"moduleMigration.optionDecideLater": "Decide it later",
|
||||
"moduleMigration.optionEnableBoth": "Enable both",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Enable only #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Enable only #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Yes, I have",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Keep previous behaviour",
|
||||
"moduleMigration.optionManualSetup": "Set it up all manually",
|
||||
"moduleMigration.optionNoAskAgain": "No, please ask again",
|
||||
"moduleMigration.optionNoSetupUri": "No, I do not have",
|
||||
"moduleMigration.optionRemindNextLaunch": "Remind me at the next launch",
|
||||
"moduleMigration.optionSetupViaP2P": "Use %{short_p2p_sync} to set up",
|
||||
"moduleMigration.optionSetupWizard": "Take me into the setup wizard",
|
||||
"moduleMigration.optionYesFetchAgain": "Yes, fetch again",
|
||||
"moduleMigration.titleCaseSensitivity": "Case Sensitivity",
|
||||
"moduleMigration.titleRecommendSetupUri": "Recommendation to use Setup URI",
|
||||
"moduleMigration.titleWelcome": "Welcome to Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Replicate",
|
||||
"More actions": "More actions",
|
||||
"Mostly Complete: Decision Required": "Mostly Complete: Decision Required",
|
||||
@@ -564,12 +559,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Enable",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Fix",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "I got it and updated.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Next",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Start",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Test",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Use",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Fetch",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Next",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Default",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "This is the recommended method to set up Self-hosted LiveSync with a Setup URI.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "Perfect for setting up a new device!",
|
||||
@@ -633,7 +626,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Set chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "We recommend enabling End-To-End Encryption, and Path Obfuscation. Are you sure you want to continue without encryption?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Do you want to fetch the config from the remote server?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "All done! Do you want to generate a setup URI to set up other devices?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "If the server configuration is not persistent (e.g., running on docker), the values here may change. Once you are able to connect, please update the settings in the server's local.ini.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Your encryption passphrase might be invalid. Are you sure you want to continue?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "Here due to an upgrade notification? Please review the version history. If you're satisfied, click the button. A new update will prompt this again.",
|
||||
@@ -643,7 +635,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "WARNING: This feature is a Work In Progress, so please keep in mind the following:\n- Append only architecture. A rebuild is required to shrink the storage.\n- A bit fragile.\n- When first syncing, all history will be transferred from the remote. Be mindful of data caps and slow speeds.\n- Only differences are synced live.\n\nIf you run into any issues, or have ideas about this feature, please create a issue on GitHub.\nI appreciate you for your great dedication.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Origin check: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "Rebuilding Databases are required to apply the changes.. Please select the method to apply the changes.\n\n<details>\n<summary>Legends</summary>\n\n| Symbol | Meaning |\n|: ------ :| ------- |\n| ⇔ | Up to Date |\n| ⇄ | Synchronise to balance |\n| ⇐,⇒ | Transfer to overwrite |\n| ⇠,⇢ | Transfer to overwrite from other side |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nAt a glance: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nReconstruct both the local and remote databases using existing files from this device.\nThis causes a lockout other devices, and they need to perform fetching.\n## ${OPTION_FETCH}\nAt a glance: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nInitialise the local database and reconstruct it using data fetched from the remote database.\nThis case includes the case which you have rebuilt the remote database.\n## ${OPTION_ONLY_SETTING}\nStore only the settings. **Caution: This may lead to data corruption**; database reconstruction is generally necessary.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Please select and apply any preset item to complete the wizard.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Set cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Set cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Set couchdb.max_document_size",
|
||||
@@ -698,19 +689,24 @@
|
||||
"obsidianLiveSyncSettingTab.panelSetup": "Setup",
|
||||
"obsidianLiveSyncSettingTab.serverVersion": "Server info: ${info}",
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Active Remote Server",
|
||||
"obsidianLiveSyncSettingTab.titleAdvancedSettings": "Advanced settings",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Appearance",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Conflict resolution",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Congratulations!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Deletion Propagation",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Encryption is not enabled",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionPassphraseInvalid": "Encryption Passphrase Invalid",
|
||||
"obsidianLiveSyncSettingTab.titleExtraFeatures": "Enable extra and advanced features",
|
||||
"obsidianLiveSyncSettingTab.titleExtraFeaturesGroup": "Extra features",
|
||||
"obsidianLiveSyncSettingTab.titleExtraMenus": "Extra menus",
|
||||
"obsidianLiveSyncSettingTab.titleFetchConfig": "Fetch Config",
|
||||
"obsidianLiveSyncSettingTab.titleFetchConfigFromRemote": "Fetch config from remote server",
|
||||
"obsidianLiveSyncSettingTab.titleFetchSettings": "Fetch Settings",
|
||||
"obsidianLiveSyncSettingTab.titleHelpAndInformation": "Help and information",
|
||||
"obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting": "Help and troubleshooting",
|
||||
"obsidianLiveSyncSettingTab.titleHiddenFiles": "Hidden Files",
|
||||
"obsidianLiveSyncSettingTab.titleLogging": "Logging",
|
||||
"obsidianLiveSyncSettingTab.titleMaintenanceAndRecovery": "Maintenance and recovery",
|
||||
"obsidianLiveSyncSettingTab.titleMinioS3R2": "Minio,S3,R2",
|
||||
"obsidianLiveSyncSettingTab.titleNotification": "Notification",
|
||||
"obsidianLiveSyncSettingTab.titleOnlineTips": "Online Tips",
|
||||
@@ -719,7 +715,8 @@
|
||||
"obsidianLiveSyncSettingTab.titleRemoteConfigCheckFailed": "Remote Configuration Check Failed",
|
||||
"obsidianLiveSyncSettingTab.titleRemoteServer": "Remote Server",
|
||||
"obsidianLiveSyncSettingTab.titleReset": "Reset",
|
||||
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": "To setup other devices",
|
||||
"obsidianLiveSyncSettingTab.titleSetupOtherDevices": "Set up other devices",
|
||||
"obsidianLiveSyncSettingTab.titleSynchronisation": "Synchronisation",
|
||||
"obsidianLiveSyncSettingTab.titleSynchronizationMethod": "Synchronization Method",
|
||||
"obsidianLiveSyncSettingTab.titleSynchronizationPreset": "Synchronization Preset",
|
||||
"obsidianLiveSyncSettingTab.titleSyncSettings": "Sync Settings",
|
||||
@@ -878,7 +875,7 @@
|
||||
"Replicator.Message.InitialiseFatalError": "No replicator is available, this is the fatal error.",
|
||||
"Replicator.Message.Pending": "Some file events are pending. Replication has been cancelled.",
|
||||
"Replicator.Message.SomeModuleFailed": "Replication has been cancelled by some module failure",
|
||||
"Replicator.Message.VersionUpFlash": "An update has been detected. Please open the Settings dialogue and check the Change Log. Replication has been cancelled.",
|
||||
"Replicator.Message.VersionUpFlash": "Remote synchronisation is paused for compatibility review. Run the 'Review why synchronisation is paused' command for details and available actions.",
|
||||
"Requires restart of Obsidian": "Requires restart of Obsidian",
|
||||
"Requires restart of Obsidian.": "Requires restart of Obsidian.",
|
||||
"Rerun Onboarding Wizard": "Rerun Onboarding Wizard",
|
||||
@@ -919,7 +916,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",
|
||||
@@ -1028,6 +1027,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",
|
||||
@@ -1069,8 +1069,10 @@
|
||||
"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.",
|
||||
"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,",
|
||||
"The delay for consecutive on-demand fetches": "The delay for consecutive on-demand fetches",
|
||||
"The files in this Vault are almost identical to the server's.": "The files in this Vault are almost identical to the server's.",
|
||||
@@ -1097,9 +1099,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",
|
||||
@@ -1330,6 +1334,28 @@
|
||||
"Ui.Settings.SyncSettings.Fetch": "Fetch",
|
||||
"Ui.Settings.SyncSettings.Merge": "Merge",
|
||||
"Ui.Settings.SyncSettings.Overwrite": "Overwrite",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": "Apply without Initialisation",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Back": "Review another way to apply these settings",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": "Apply Settings without Initialisation?",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": "Continue with Fetch",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": "Reset Synchronisation on This Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": "Keep Editing",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": "Restart and Fetch Synchronisation Data",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": "Restart and Select a Source Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": "Restart and Overwrite Server Data",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": "Restart and Prepare This Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Question": "Which existing data should be used after restart?",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": "Overwrite Server Data with This Device's Files",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": "Prepare This Device from This Vault",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": "Remote Synchronisation Data Could Not Be Verified",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Title": "Apply Settings and Reinitialise Synchronisation",
|
||||
"Ui.SetupWizard.Common.Back": "No, please take me back",
|
||||
"Ui.SetupWizard.Common.Cancel": "Cancel",
|
||||
"Ui.SetupWizard.Common.ProceedSelectOption": "Please select an option to proceed",
|
||||
@@ -1448,5 +1474,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.",
|
||||
@@ -479,7 +485,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram habilitado",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Esperando a que la base de datos esté lista...",
|
||||
"moduleLog.showLog": "Mostrar registro",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Comprobarlo más tarde",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "Ya lo he corregido, no volver a preguntar",
|
||||
"moduleMigration.fix0256.buttons.fix": "Corregir",
|
||||
@@ -501,26 +506,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "No se pudieron obtener los valores de ajuste remoto",
|
||||
"moduleMigration.logSetupCancelled": "La configuración ha sido cancelada, ¡Self-hosted LiveSync está esperando tu configuración!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "Como ya sabrás, Self-hosted LiveSync ha cambiado su comportamiento predeterminado y la estructura de la base de datos.\n\nAfortunadamente, con tu tiempo y esfuerzo, la base de datos remota parece haber sido ya migrada. ¡Felicidades!\n\nSin embargo, necesitamos un poco más. La configuración de este dispositivo no es compatible con la base de datos remota. Necesitaremos volver a obtener la base de datos remota. ¿Debemos obtenerla nuevamente ahora?\n\n___Nota: No podemos sincronizar hasta que la configuración haya sido cambiada y la base de datos haya sido obtenida nuevamente.___\n___Nota2: Los fragmentos son completamente inmutables, solo podemos obtener los metadatos y diferencias.___",
|
||||
"moduleMigration.msgInitialSetup": "Tu dispositivo **aún no ha sido configurado**. Permíteme guiarte a través del proceso de configuración.\n\nTen en cuenta que todo el contenido del diálogo se puede copiar al portapapeles. Si necesitas consultarlo más tarde, puedes pegarlo en una nota en Obsidian. También puedes traducirlo a tu idioma utilizando una herramienta de traducción.\n\nPrimero, ¿tienes **URI de configuración**?\n\nNota: Si no sabes qué es, consulta la [documentación](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "Te recomendamos encarecidamente que generes una URI de configuración y la utilices.\nSi no tienes conocimientos al respecto, consulta la [documentación](${URI_DOC}) (Lo siento de nuevo, pero es importante).\n\n¿Cómo quieres configurarlo manualmente?",
|
||||
"moduleMigration.msgSinceV02321": "Desde la versión v0.23.21, Self-hosted LiveSync ha cambiado el comportamiento predeterminado y la estructura de la base de datos. Se han realizado los siguientes cambios:\n\n1. **Sensibilidad a mayúsculas de los nombres de archivo**\n El manejo de los nombres de archivo ahora no distingue entre mayúsculas y minúsculas. Este cambio es beneficioso para la mayoría de las plataformas, excepto Linux y iOS, que no gestionan efectivamente la sensibilidad a mayúsculas de los nombres de archivo.\n (En estos, se mostrará una advertencia para archivos con el mismo nombre pero diferentes mayúsculas).\n\n2. **Manejo de revisiones de los fragmentos**\n Los fragmentos son inmutables, lo que permite que sus revisiones sean fijas. Este cambio mejorará el rendimiento al guardar archivos.\n\n___Sin embargo, para habilitar cualquiera de estos cambios, es necesario reconstruir tanto las bases de datos remota como la local. Este proceso toma unos minutos, y recomendamos hacerlo cuando tengas tiempo suficiente.___\n\n- Si deseas mantener el comportamiento anterior, puedes omitir este proceso usando `${KEEP}`.\n- Si no tienes suficiente tiempo, por favor elige `${DISMISS}`. Se te pedirá nuevamente más tarde.\n- Si has reconstruido la base de datos en otro dispositivo, selecciona `${DISMISS}` e intenta sincronizar nuevamente. Dado que se ha detectado una diferencia, se te solicitará nuevamente.",
|
||||
"moduleMigration.optionAdjustRemote": "Ajustar al remoto",
|
||||
"moduleMigration.optionDecideLater": "Decidirlo más tarde",
|
||||
"moduleMigration.optionEnableBoth": "Habilitar ambos",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Habilitar solo #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Habilitar solo #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Sí, tengo",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Mantener comportamiento anterior",
|
||||
"moduleMigration.optionManualSetup": "Configurarlo todo manualmente",
|
||||
"moduleMigration.optionNoAskAgain": "No, por favor pregúntame de nuevo",
|
||||
"moduleMigration.optionNoSetupUri": "No, no tengo",
|
||||
"moduleMigration.optionRemindNextLaunch": "Recordármelo en el próximo inicio",
|
||||
"moduleMigration.optionSetupViaP2P": "Usar %{short_p2p_sync} para configurarlo",
|
||||
"moduleMigration.optionSetupWizard": "Llévame al asistente de configuración",
|
||||
"moduleMigration.optionYesFetchAgain": "Sí, obtener de nuevo",
|
||||
"moduleMigration.titleCaseSensitivity": "Distinción de mayúsculas y minúsculas",
|
||||
"moduleMigration.titleRecommendSetupUri": "Recomendación de usar un Setup URI",
|
||||
"moduleMigration.titleWelcome": "Bienvenido a Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Replicar",
|
||||
"More actions": "Más acciones",
|
||||
"Mostly Complete: Decision Required": "Casi terminado: se requiere una decisión",
|
||||
@@ -565,12 +560,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Activar",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Corregir",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "Lo entendí y actualicé.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Siguiente",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Iniciar",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Probar",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Usar",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Obtener",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Siguiente",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Predeterminado",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "Este es el método recomendado para configurar Self-hosted LiveSync con una URI de configuración.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "¡Perfecto para configurar un nuevo dispositivo!",
|
||||
@@ -606,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}",
|
||||
@@ -633,7 +626,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Establecer chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Recomendamos habilitar el cifrado de extremo a extremo y la obfuscación de ruta. ¿Estás seguro de querer continuar sin cifrado?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "¿Quieres obtener la configuración del servidor remoto?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "¡Todo listo! ¿Quieres generar un URI de configuración para configurar otros dispositivos?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Si la configuración del servidor no es persistente (por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una vez que puedas conectarte, por favor actualiza las configuraciones en el local.ini del servidor.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Tu frase de contraseña de cifrado podría ser inválida. ¿Estás seguro de querer continuar?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "¿Aquí debido a una notificación de actualización? Por favor, revise el historial de versiones. Si está satisfecho, haga clic en el botón. Una nueva actualización volverá a mostrar esto.",
|
||||
@@ -643,7 +635,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "ADVERTENCIA: Esta característica está en desarrollo, así que por favor ten en cuenta lo siguiente:\n- Arquitectura de solo anexado. Se requiere una reconstrucción para reducir el almacenamiento.\n- Un poco frágil.\n- Al sincronizar por primera vez, todo el historial será transferido desde el remoto. Ten en cuenta los límites de datos y las velocidades lentas.\n- Solo las diferencias se sincronizan en vivo.\n\nSi encuentras algún problema o tienes ideas sobre esta característica, por favor crea un issue en GitHub.\nAprecio mucho tu gran dedicación.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Verificación de origen: {org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "Es necesario reconstruir las bases de datos para aplicar los cambios. Por favor selecciona el método para aplicar los cambios.\n\n<details>\n<summary>Legendas</summary>\n\n| Símbolo | Significado |\n|: ------ :| ------- |\n| ⇔ | Actualizado |\n| ⇄ | Sincronizar para equilibrar |\n| ⇐,⇒ | Transferir para sobrescribir |\n| ⇠,⇢ | Transferir para sobrescribir desde otro lado |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nA simple vista: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nReconstruir tanto la base de datos local como la remota utilizando los archivos existentes de este dispositivo.\nEsto bloquea a otros dispositivos, y necesitan realizar la obtención.\n## ${OPTION_FETCH}\nA simple vista: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nInicializa la base de datos local y la reconstruye utilizando los datos obtenidos de la base de datos remota.\nEste caso incluye el caso en el que has reconstruido la base de datos remota.\n## ${OPTION_ONLY_SETTING}\nAlmacena solo la configuración. **Precaución: esto puede provocar corrupción de datos**; generalmente es necesario reconstruir la base de datos.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Por favor, selecciona y aplica cualquier elemento preestablecido para completar el asistente.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Configurar cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Configurar cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Configurar couchdb.max_document_size",
|
||||
@@ -663,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",
|
||||
@@ -692,15 +683,14 @@
|
||||
"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}",
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Servidor remoto activo",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Apariencia",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Resolución de conflictos",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "¡Felicidades!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "Servidor CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Propagación de eliminación",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "El cifrado no está habilitado",
|
||||
@@ -722,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}",
|
||||
@@ -919,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",
|
||||
@@ -1069,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",
|
||||
@@ -1110,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,",
|
||||
@@ -1138,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",
|
||||
@@ -1489,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!)"
|
||||
}
|
||||
|
||||
@@ -190,7 +190,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Mode Scram activé",
|
||||
"moduleLocalDatabase.logWaitingForReady": "En attente de disponibilité...",
|
||||
"moduleLog.showLog": "Afficher le journal",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Vérifier plus tard",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "J'ai corrigé, et ne plus demander",
|
||||
"moduleMigration.fix0256.buttons.fix": "Corriger",
|
||||
@@ -212,26 +211,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "Impossible d'obtenir les valeurs d'ajustement distantes",
|
||||
"moduleMigration.logSetupCancelled": "La configuration a été annulée, Self-hosted LiveSync attend votre configuration !",
|
||||
"moduleMigration.msgFetchRemoteAgain": "Comme vous le savez peut-être déjà, Self-hosted LiveSync a modifié son comportement par défaut et la structure de sa base de données.\n\nEt, grâce à votre temps et vos efforts, la base distante semble déjà avoir été migrée. Félicitations !\n\nCependant, il faut encore un peu plus. La configuration de cet appareil n'est pas compatible avec la base distante. Nous devrons récupérer à nouveau la base distante. Devons-nous récupérer depuis le distant maintenant ?\n\n___Note : Nous ne pouvons pas synchroniser tant que la configuration n'a pas été modifiée et que la base n'a pas été récupérée à nouveau.___\n___Note 2 : Les fragments sont complètement immuables, nous ne pouvons récupérer que les métadonnées et les différences.___",
|
||||
"moduleMigration.msgInitialSetup": "Votre appareil n'a **pas encore été configuré**. Laissez-moi vous guider dans le processus de configuration.\n\nVeuillez noter que chaque contenu de boîte de dialogue peut être copié dans le presse-papiers. Si vous souhaitez vous y référer plus tard, vous pouvez le coller dans une note d'Obsidian. Vous pouvez également le traduire dans votre langue via un outil de traduction.\n\nTout d'abord, disposez-vous d'une **URI de configuration** ?\n\nNote : Si vous ne savez pas ce que c'est, consultez la [documentation](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "Nous recommandons vivement de générer une URI de configuration et de l'utiliser.\nSi vous ne connaissez pas, veuillez consulter la [documentation](${URI_DOC}) (Désolé encore, mais c'est important).\n\nComment souhaitez-vous effectuer la configuration manuellement ?",
|
||||
"moduleMigration.msgSinceV02321": "Depuis la v0.23.21, Self-hosted LiveSync a modifié son comportement par défaut et la structure de sa base. Les changements suivants ont été effectués :\n\n1. **Sensibilité à la casse des noms de fichiers**\n La gestion des noms de fichiers est désormais insensible à la casse. C'est un changement bénéfique pour la plupart des plateformes, hormis Linux et iOS, qui ne gèrent pas efficacement la casse des noms de fichiers.\n (Sur celles-ci, un avertissement s'affichera pour les fichiers portant le même nom avec une casse différente).\n\n2. **Gestion des révisions des fragments**\n Les fragments sont immuables, ce qui permet de fixer leurs révisions. Ce changement améliore les performances d'enregistrement des fichiers.\n\n___Cependant, pour activer l'un ou l'autre de ces changements, les bases locale et distante doivent être reconstruites. Ce processus prend quelques minutes, et nous recommandons de le faire quand vous avez le temps.___\n\n- Si vous souhaitez conserver le comportement précédent, vous pouvez ignorer ce processus via `${KEEP}`.\n- Si vous n'avez pas le temps, choisissez `${DISMISS}`. Vous serez invité à nouveau plus tard.\n- Si vous avez reconstruit la base sur un autre appareil, sélectionnez `${DISMISS}` et réessayez la synchronisation. Une différence étant détectée, vous serez invité à nouveau.",
|
||||
"moduleMigration.optionAdjustRemote": "Ajuster au distant",
|
||||
"moduleMigration.optionDecideLater": "Décider plus tard",
|
||||
"moduleMigration.optionEnableBoth": "Activer les deux",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Activer seulement #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Activer seulement #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Oui, j'en ai une",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Conserver le comportement précédent",
|
||||
"moduleMigration.optionManualSetup": "Tout configurer manuellement",
|
||||
"moduleMigration.optionNoAskAgain": "Non, demandez à nouveau",
|
||||
"moduleMigration.optionNoSetupUri": "Non, je n'en ai pas",
|
||||
"moduleMigration.optionRemindNextLaunch": "Me rappeler au prochain lancement",
|
||||
"moduleMigration.optionSetupViaP2P": "Utiliser %{short_p2p_sync} pour configurer",
|
||||
"moduleMigration.optionSetupWizard": "Ouvrir l'assistant de configuration",
|
||||
"moduleMigration.optionYesFetchAgain": "Oui, récupérer à nouveau",
|
||||
"moduleMigration.titleCaseSensitivity": "Sensibilité à la casse",
|
||||
"moduleMigration.titleRecommendSetupUri": "Recommandation d'utilisation de l'URI de configuration",
|
||||
"moduleMigration.titleWelcome": "Bienvenue dans Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Répliquer",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "Déplacer les fichiers supprimés à distance vers la corbeille, au lieu de les supprimer.",
|
||||
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "Tous les messages n'ont pas été traduits. Et veuillez revenir à « Par défaut » lorsque vous signalez des erreurs.",
|
||||
@@ -249,12 +238,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Activer",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Corriger",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "J'ai compris et mis à jour.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Suivant",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Démarrer",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Tester",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Utiliser",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Récupérer",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Suivant",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "Par défaut",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "Méthode recommandée pour configurer Self-hosted LiveSync avec une URI de configuration.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "Parfait pour configurer un nouvel appareil !",
|
||||
@@ -317,7 +304,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Définir chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Nous recommandons d'activer le chiffrement de bout en bout et l'obfuscation des chemins. Êtes-vous sûr de vouloir continuer sans chiffrement ?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Voulez-vous récupérer la configuration depuis le serveur distant ?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "Tout est prêt ! Voulez-vous générer une URI de configuration pour configurer d'autres appareils ?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Si la configuration du serveur n'est pas persistante (par ex. fonctionnant sur Docker), les valeurs peuvent changer. Une fois la connexion établie, mettez à jour les paramètres dans le local.ini du serveur.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Votre phrase secrète de chiffrement peut être invalide. Êtes-vous sûr de vouloir continuer ?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "Arrivé ici suite à une notification de mise à jour ? Consultez l'historique des versions. Si vous êtes satisfait, cliquez sur le bouton. Une nouvelle mise à jour reproposera ceci.",
|
||||
@@ -327,7 +313,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "AVERTISSEMENT : cette fonctionnalité est en cours de développement, gardez à l'esprit ce qui suit :\n- Architecture en ajout seul. Une reconstruction est nécessaire pour réduire le stockage.\n- Un peu fragile.\n- Lors de la première synchronisation, tout l'historique sera transféré depuis le distant. Attention aux limites de données et aux débits lents.\n- Seules les différences sont synchronisées en direct.\n\nSi vous rencontrez des problèmes ou avez des idées sur cette fonctionnalité, merci d'ouvrir un ticket sur GitHub.\nMerci pour votre grand dévouement.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Vérification d'origine : ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "La reconstruction des bases de données est nécessaire pour appliquer les changements. Veuillez sélectionner la méthode d'application.\n\n<details>\n<summary>Légende</summary>\n\n| Symbole | Signification |\n|: ------ :| ------- |\n| ⇔ | À jour |\n| ⇄ | Synchroniser pour équilibrer |\n| ⇐,⇒ | Transférer pour écraser |\n| ⇠,⇢ | Transférer pour écraser depuis l'autre côté |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nEn bref : 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nReconstruit les bases locale et distante à partir des fichiers existants de cet appareil.\nCeci provoque un verrouillage des autres appareils, qui devront effectuer une récupération.\n## ${OPTION_FETCH}\nEn bref : 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nInitialise la base locale et la reconstruit à partir des données récupérées depuis la base distante.\nCe cas inclut également celui où vous avez reconstruit la base distante.\n## ${OPTION_ONLY_SETTING}\nNe stocker que les paramètres. **Attention : cela peut entraîner une corruption des données** ; une reconstruction de la base est généralement nécessaire.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Veuillez sélectionner et appliquer un préréglage pour terminer l'assistant.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Définir cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Définir cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Définir couchdb.max_document_size",
|
||||
@@ -384,7 +369,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Serveur distant actif",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Apparence",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Résolution des conflits",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Félicitations !",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Propagation des suppressions",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Le chiffrement n'est pas activé",
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "מצב בלימה פעיל",
|
||||
"moduleLocalDatabase.logWaitingForReady": "ממתין לכשירות...",
|
||||
"moduleLog.showLog": "הצג יומן",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "בדוק מאוחר יותר",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "תיקנתי, ואל תשאל שוב",
|
||||
"moduleMigration.fix0256.buttons.fix": "תקן",
|
||||
@@ -213,26 +212,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "לא ניתן לקבל ערכי כיוונון מרוחקים",
|
||||
"moduleMigration.logSetupCancelled": "ההגדרה בוטלה, Self-hosted LiveSync ממתין להגדרתך!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "כפי שייתכן שכבר ידוע לך, Self-hosted LiveSync שינה את התנהגות ברירת המחדל ומבנה מסד הנתונים.\n\nובזכות זמנך ומאמציך, מסד הנתונים המרוחד נראה כבר הוגר. ברכות!\n\nעם זאת, נדרש עוד קצת. תצורת מכשיר זה אינה תואמת למסד הנתונים המרוחד. נצטרך למשוך את מסד הנתונים המרוחד שוב. האם למשוך מהשרת המרוחד עכשיו?\n\n___הערה: לא ניתן לסנכרן עד שהתצורה תשתנה ומסד הנתונים יימשך שוב.___\n___הערה 2: הנתחים הם בלתי-ניתנים לשינוי לחלוטין, ניתן למשוך רק את המטה-נתונים וההפרש.___",
|
||||
"moduleMigration.msgInitialSetup": "המכשיר שלך **טרם הוגדר**. אנחנו כאן לעזור לך בתהליך ההגדרה.\n\nשים לב שניתן להעתיק את תוכן כל דיאלוג ללוח. אם צריך לחזור אליו מאוחר יותר, ניתן להדביק אותו כפתק ב-Obsidian. ניתן גם לתרגם לשפתך בעזרת כלי תרגום.\n\nראשית, האם יש לך **Setup URI**?\n\nהערה: אם אינך יודע מהו, אנא עיין ב[תיעוד](${URI_DOC}).",
|
||||
"moduleMigration.msgRecommendSetupUri": "אנו ממליצים בחום לייצר Setup URI ולהשתמש בו.\nאם אין לך ידע בנושא, אנא עיין ב[תיעוד](${URI_DOC}) (מתנצלים שוב, אך זה חשוב).\n\nכיצד ברצונך להגדיר ידנית?",
|
||||
"moduleMigration.msgSinceV02321": "מאז גרסה 0.23.21, Self-hosted LiveSync שינה את התנהגות ברירת המחדל ומבנה מסד הנתונים. השינויים הבאים בוצעו:\n\n1. **תלות רישיות בשמות קבצים**\n הטיפול בשמות קבצים הוא כעת ללא תלות רישיות. זהו שינוי מועיל לרוב הפלטפורמות,\n פרט ל-Linux ו-iOS שאינן מנהלות תלות רישיות בקבצים ביעילות.\n (בפלטפורמות אלה, תוצג אזהרה עבור קבצים עם אותו שם אך רישיות שונה).\n\n2. **טיפול בגרסאות של נתחים**\n נתחים הם בלתי-ניתנים לשינוי, מה שמאפשר גרסאות קבועות. שינוי זה ישפר את\n ביצועי שמירת הקבצים.\n\n___עם זאת, כדי להפעיל אחד מהשינויים הללו, יש לבנות מחדש גם את מסד הנתונים המרוחד וגם את המקומי. תהליך זה לוקח כמה דקות, ואנו ממליצים לעשות זאת כשיש לך זמן פנוי.___\n\n- אם ברצונך לשמור את ההתנהגות הקודמת, ניתן לדלג על תהליך זה באמצעות `${KEEP}`.\n- אם אין לך מספיק זמן, אנא בחר `${DISMISS}`. תקבל תזכורת בהמשך.\n- אם בנית מחדש את מסד הנתונים במכשיר אחר, אנא בחר `${DISMISS}` ונסה לסנכרן שוב. מאחר שזוהה הפרש, תקבל תזכורת שוב.",
|
||||
"moduleMigration.optionAdjustRemote": "התאם לשרת המרוחד",
|
||||
"moduleMigration.optionDecideLater": "החלט מאוחר יותר",
|
||||
"moduleMigration.optionEnableBoth": "הפעל את שניהם",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "הפעל רק #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "הפעל רק #2",
|
||||
"moduleMigration.optionHaveSetupUri": "כן, יש לי",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "שמור על התנהגות קודמת",
|
||||
"moduleMigration.optionManualSetup": "הגדר הכל ידנית",
|
||||
"moduleMigration.optionNoAskAgain": "לא, אנא שאל שוב",
|
||||
"moduleMigration.optionNoSetupUri": "לא, אין לי",
|
||||
"moduleMigration.optionRemindNextLaunch": "הזכר לי בהפעלה הבאה",
|
||||
"moduleMigration.optionSetupViaP2P": "השתמש ב-%{short_p2p_sync} להגדרה",
|
||||
"moduleMigration.optionSetupWizard": "קח אותי לאשף ההגדרה",
|
||||
"moduleMigration.optionYesFetchAgain": "כן, משוך שוב",
|
||||
"moduleMigration.titleCaseSensitivity": "תלות רישיות",
|
||||
"moduleMigration.titleRecommendSetupUri": "המלצה לשימוש ב-Setup URI",
|
||||
"moduleMigration.titleWelcome": "ברוך הבא ל-Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "שכפל",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "העבר קבצים שנמחקו מרחוק לאשפה, במקום למחוק.",
|
||||
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "לא כל ההודעות תורגמו. בנוסף, אנא חזור ל\"ברירת מחדל\" בעת דיווח על שגיאות.",
|
||||
@@ -250,12 +239,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "הפעל",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "תקן",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "הבנתי ועדכנתי.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "הבא",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "התחל",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "בדוק",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "השתמש",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "משוך",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "הבא",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "ברירת מחדל",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "זוהי השיטה המומלצת להגדרת Self-hosted LiveSync עם Setup URI.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "מושלם להגדרת מכשיר חדש!",
|
||||
@@ -318,7 +305,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "הגדר chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "אנו ממליצים להפעיל הצפנה מקצה לקצה ואת ערפול הנתיב. האם אתה בטוח שברצונך להמשיך ללא הצפנה?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "האם ברצונך למשוך את התצורה מהשרת המרוחד?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "הכל מוכן! האם ברצונך לייצר Setup URI להגדרת מכשירים אחרים?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "אם תצורת השרת אינה קבועה (למשל, פועלת ב-docker), הערכים כאן עשויים להשתנות. לאחר שתצליח להתחבר, אנא עדכן את ההגדרות ב-local.ini של השרת.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "ביטוי הסיסמה להצפנה שלך עשוי להיות לא תקין. האם אתה בטוח שברצונך להמשיך?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "הגעת כאן בשל הודעת שדרוג? אנא עיין בהיסטוריית הגרסאות. אם אתה מרוצה, לחץ על הכפתור. עדכון חדש יציג זאת שוב.",
|
||||
@@ -328,7 +314,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "אזהרה: תכונה זו בשלב פיתוח, לכן שים לב לנקודות הבאות:\n- ארכיטקטורת הוספה בלבד. נדרשת בנייה מחדש לצמצום האחסון.\n- קצת רגיש.\n- בסנכרון הראשון, כל ההיסטוריה תועבר מהשרת המרוחד. שים לב למגבלות נתונים ומהירות.\n- רק הפרשים מסונכרנים בזמן אמת.\n\nאם נתקלת בבעיות, או שיש לך רעיונות לגבי תכונה זו, אנא פתח Issue ב-GitHub.\nאנחנו מעריכים את ההקדשה הגדולה שלך.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "בדיקת מקור: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "נדרשת בנייה מחדש של מסדי הנתונים כדי להחיל את השינויים. אנא בחר את השיטה.\n\n<details>\n<summary>מקרא</summary>\n\n| סמל | משמעות |\n|: ------ :| ------- |\n| ⇔ | מעודכן |\n| ⇄ | סנכרן לאיזון |\n| ⇐,⇒ | העבר לדריסה |\n| ⇠,⇢ | העבר לדריסה מהצד השני |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\nבמבט: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nבנה מחדש גם את מסד הנתונים המקומי וגם המרוחד תוך שימוש בקבצים קיימים ממכשיר זה.\nפעולה זו תנעל מכשירים אחרים שיצטרכו לבצע משיכה.\n## ${OPTION_FETCH}\nבמבט: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nאתחל את מסד הנתונים המקומי ובנה אותו מחדש תוך שימוש בנתונים שנמשכו ממסד הנתונים המרוחד.\nכולל את המקרה שבו בנית מחדש את מסד הנתונים המרוחד.\n## ${OPTION_ONLY_SETTING}\nשמור רק את ההגדרות. **זהירות: עלול לגרום לפגיעה בנתונים**; בנייה מחדש של מסד הנתונים נדרשת בדרך כלל.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "אנא בחר והחל פריט קבוע מראש כלשהו להשלמת האשף.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "הגדר cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "הגדר cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "הגדר couchdb.max_document_size",
|
||||
@@ -385,7 +370,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "שרת מרוחד פעיל",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "מראה",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "פתרון קונפליקטים",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "מזל טוב!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "הפצת מחיקות",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "ההצפנה אינה מופעלת",
|
||||
|
||||
@@ -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 がある場合は、それを使うと簡単かつ安全に設定できます。",
|
||||
@@ -291,7 +291,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "緊急停止(Scram)が有効",
|
||||
"moduleLocalDatabase.logWaitingForReady": "しばらくお待ちください...",
|
||||
"moduleLog.showLog": "ログを表示",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "後で確認する",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "修正済み、今後確認しない",
|
||||
"moduleMigration.fix0256.buttons.fix": "修正",
|
||||
@@ -313,26 +312,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "リモートの調整値を取得できませんでした",
|
||||
"moduleMigration.logSetupCancelled": "セットアップがキャンセルされました。Self-hosted LiveSyncはセットアップを待っています!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "ご存知のとおり、self-hosted LiveSyncはデフォルトの動作とデータベース構造を変更しました。\n\nご協力のおかげで、リモートデータベースはすでに移行されているようです。おめでとうございます!\n\nしかし、もう少し必要です。このデバイスの設定はリモートデータベースと互換性がありません。リモートデータベースを再度フェッチする必要があります。今すぐリモートから再フェッチしますか?\n\n___注意: 設定が変更され、データベースが再フェッチされるまで同期できません。___\n___注意2: チャンクは完全に不変なので、メタデータと差分のみフェッチできます。___",
|
||||
"moduleMigration.msgInitialSetup": "このデバイスは**まだセットアップされていません**。セットアッププロセスをご案内します。\n\nすべてのダイアログの内容はクリップボードにコピーできます。後で参照する必要があれば、Obsidianのノートに貼り付けてください。翻訳ツールを使ってお使いの言語に翻訳することもできます。\n\nまず、**セットアップURI**をお持ちですか?\n\n注意: それが何か分からない場合は、[documentation](${URI_DOC})を参照してください。",
|
||||
"moduleMigration.msgRecommendSetupUri": "セットアップURIを生成して使用することを強くお勧めします。\nこれについて知識がない場合は、[documentation](${URI_DOC})を参照してください(重要です)。\n\n手動でセットアップしますか?",
|
||||
"moduleMigration.msgSinceV02321": "v0.23.21以降、self-hosted LiveSyncはデフォルトの動作とデータベース構造を変更しました。以下の変更が行われました:\n\n1. **ファイル名の大文字小文字の区別**\n ファイル名の処理が大文字小文字を区別しなくなりました。これは、ファイル名の大文字小文字を効果的に管理しないLinuxとiOS以外のほとんどのプラットフォームにとって有益な変更です。\n (これらの環境では、同じ名前で大文字小文字が異なるファイルに対して警告が表示されます)。\n\n2. **チャンクのリビジョン処理**\n チャンクは不変であり、リビジョンを固定できます。この変更により、ファイル保存のパフォーマンスが向上します。\n\n___しかし、これらの変更を有効にするには、リモートとローカルの両方のデータベースを再構築する必要があります。このプロセスは数分かかります。時間に余裕があるときに行うことをお勧めします。___\n\n- 以前の動作を維持したい場合は、`${KEEP}`を使用してこのプロセスをスキップできます。\n- 時間がない場合は、`${DISMISS}`を選択してください。後で再度確認されます。\n- 別のデバイスでデータベースを再構築した場合は、`${DISMISS}`を選択して再度同期してみてください。差異が検出されたため、再度確認されます。",
|
||||
"moduleMigration.optionAdjustRemote": "リモートに合わせる",
|
||||
"moduleMigration.optionDecideLater": "後で決める",
|
||||
"moduleMigration.optionEnableBoth": "両方を有効にする",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "#1のみ有効にする",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "#2のみ有効にする",
|
||||
"moduleMigration.optionHaveSetupUri": "はい、持っています",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "以前の動作を維持",
|
||||
"moduleMigration.optionManualSetup": "すべて手動でセットアップ",
|
||||
"moduleMigration.optionNoAskAgain": "いいえ、後で確認する",
|
||||
"moduleMigration.optionNoSetupUri": "いいえ、持っていません",
|
||||
"moduleMigration.optionRemindNextLaunch": "次回起動時にリマインド",
|
||||
"moduleMigration.optionSetupViaP2P": "%{short_p2p_sync}を使ってセットアップ",
|
||||
"moduleMigration.optionSetupWizard": "セットアップウィザードへ",
|
||||
"moduleMigration.optionYesFetchAgain": "はい、再フェッチする",
|
||||
"moduleMigration.titleCaseSensitivity": "大文字小文字の区別",
|
||||
"moduleMigration.titleRecommendSetupUri": "セットアップURIの使用を推奨",
|
||||
"moduleMigration.titleWelcome": "Self-hosted LiveSyncへようこそ",
|
||||
"moduleObsidianMenu.replicate": "レプリケート",
|
||||
"More actions": "その他の操作",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "リモートで削除されたファイルを削除せずにゴミ箱に移動する。",
|
||||
@@ -361,12 +350,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "有効化",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "修正",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "理解しました、更新しました。",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "次へ",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "開始",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "テスト",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "使用",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "フェッチ",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "次へ",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "デフォルト",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "セットアップURIを使用してSelf-hosted LiveSyncをセットアップする推奨方法です。",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "新しいデバイスのセットアップにおすすめ!",
|
||||
@@ -429,7 +416,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "chttpd.enable_corsを設定",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "エンドツーエンド暗号化とパス難読化を有効にすることをお勧めします。暗号化なしで続行してもよろしいですか?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "リモートサーバーから設定を取得しますか?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "完了!他のデバイスをセットアップするためのセットアップURIを生成しますか?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "サーバー設定が永続的でない場合(例: Dockerで実行中)、ここの値は変更される可能性があります。接続できるようになったら、サーバーのlocal.iniの設定を更新してください。",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "暗号化パスフレーズが無効かもしれません。続行してもよろしいですか?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "アップグレード通知でここに来ましたか?バージョン履歴を確認してください。納得したらボタンをクリックしてください。新しい更新があると再度確認されます。",
|
||||
@@ -439,7 +425,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "警告: この機能は開発中です。以下の点にご注意ください:\n- 追記専用アーキテクチャ。ストレージを縮小するには再構築が必要です。\n- やや不安定です。\n- 初回同期時、すべての履歴がリモートから転送されます。データ制限と速度に注意してください。\n- ライブ同期は差分のみです。\n\n問題があれば、またはこの機能についてアイデアがあれば、GitHubにIssueを作成してください。\nご協力に感謝します。",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "オリジン確認: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "変更を適用するにはデータベースの再構築が必要です。変更を適用する方法を選択してください。\n\n<details>\n<summary>凡例</summary>\n\n| 記号 | 意味 |\n|: ------ :| ------- |\n| ⇔ | 最新 |\n| ⇄ | 同期してバランスを取る |\n| ⇐,⇒ | 上書きするため転送 |\n| ⇠,⇢ | 反対側から上書きするため転送 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n概要: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\nこのデバイスの既存ファイルを使用してローカルとリモートの両方のデータベースを再構築します。\n他のデバイスはロックアウトされ、フェッチが必要です。\n## ${OPTION_FETCH}\n概要: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\nローカルデータベースを初期化し、リモートデータベースから取得したデータを使用して再構築します。\nリモートデータベースを再構築した場合も含まれます。\n## ${OPTION_ONLY_SETTING}\n設定のみを保存します。**注意: データ破損につながる可能性があります**。通常、データベースの再構築が必要です。",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "ウィザードを完了するには、プリセット項目を選択して適用してください。",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "cors.credentialsを設定",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "cors.originsを設定",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "couchdb.max_document_sizeを設定",
|
||||
@@ -496,7 +481,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "アクティブなリモートサーバー",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "外観",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "競合解決",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "おめでとうございます!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB サーバー",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "削除の伝播",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "暗号化が有効になっていません",
|
||||
|
||||
@@ -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": "↓: 로컬 덮어쓰기",
|
||||
@@ -478,7 +478,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "긴급 정지 활성화됨",
|
||||
"moduleLocalDatabase.logWaitingForReady": "준비 대기 중...",
|
||||
"moduleLog.showLog": "로그 표시",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "나중에 확인",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "이미 해결했으니 다시 묻지 않기",
|
||||
"moduleMigration.fix0256.buttons.fix": "수정",
|
||||
@@ -500,26 +499,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "원격 조정 값을 가져올 수 없습니다",
|
||||
"moduleMigration.logSetupCancelled": "설정이 취소되었습니다. Self-hosted LiveSync가 설정을 기다리고 있습니다!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "이미 알고 계시겠지만, Self-hosted LiveSync의 기본 동작 방식과 데이터베이스 구조가 변경되었습니다.\n\n다행히도 여러분의 노력 덕분에 원격 데이터베이스는 이미 성공적으로 데이터 구조 전환이 완료된 것으로 보입니다. 축하드립니다!\n\n하지만 아직 일부 추가 작업이 필요합니다. 이 기기의 설정이 원격 데이터베이스와 호환되지 않으므로, 원격 데이터를 다시 가져와야 합니다. 지금 원격 데이터베이스를 다시 가져오시겠습니까?\n\n___참고: 설정이 변경되고 데이터베이스를 다시 불러오기 전까지는 동기화가 불가능합니다.___\n___참고2: 청크는 변경이 불가능한 구조이므로, 메타데이터와 차이점만 가져올 수 있습니다.___",
|
||||
"moduleMigration.msgInitialSetup": "이 기기는 **아직 초기 설정이 완료되지 않았습니다**. 지금부터 설정 과정을 안내해 드리겠습니다.\n\n모든 대화 내용은 클립보드에 복사할 수 있습니다. 나중에 참고하려면 Obsidian 노트에 붙여넣거나 번역 도구를 활용해 번역하셔도 됩니다.\n\n먼저, **Setup URI**를 가지고 계신가요?\n\n참고: Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요.",
|
||||
"moduleMigration.msgRecommendSetupUri": "Setup URI를 생성해 사용하는 것을 강력히 권장합니다.\nSetup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요. 중요한 내용이니 꼭 확인하시기 바랍니다.\n\n직접 수동 설정을 진행하시겠습니까?",
|
||||
"moduleMigration.msgSinceV02321": "v0.23.21부터 Self-hosted LiveSync의 기본 동작 방식과 데이터베이스 구조가 변경되었습니다. 변경 내용은 다음과 같습니다:\n\n1. **파일명의 대소문자 구분**\n 이제 파일명을 대소문자 구분 없이 처리합니다. 파일명의 대소문자를 제대로 관리하지 못하는 Linux와 iOS를 제외한 대부분의 플랫폼에서 유리한 변경입니다.\n (해당 플랫폼에서는 이름이 같고 대소문자만 다른 파일에 대해 경고가 표시됩니다)\n\n2. **청크의 리비전 처리**\n 청크는 변경 불가능하므로 리비전을 고정할 수 있습니다. 이 변경으로 파일 저장 성능이 향상됩니다.\n\n___다만 이 변경 중 어느 하나라도 적용하려면 원격과 로컬 데이터베이스를 모두 재구축해야 합니다. 이 과정은 몇 분이 걸리므로 시간이 충분할 때 진행하시기를 권장합니다.___\n\n- 기존 동작을 유지하려면 `${KEEP}`을 선택해 이 과정을 건너뛸 수 있습니다.\n- 시간이 충분하지 않다면 `${DISMISS}`를 선택해 주세요. 나중에 다시 여쭤보겠습니다.\n- 다른 기기에서 이미 데이터베이스를 재구축했다면 `${DISMISS}`를 선택한 뒤 다시 동기화해 보세요. 차이가 감지되면 다시 안내해 드립니다.",
|
||||
"moduleMigration.optionAdjustRemote": "원격에 맞추기",
|
||||
"moduleMigration.optionDecideLater": "나중에 결정하기",
|
||||
"moduleMigration.optionEnableBoth": "둘 다 활성화",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "#1만 활성화",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "#2만 활성화",
|
||||
"moduleMigration.optionHaveSetupUri": "예, 있습니다",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "이전 동작 유지",
|
||||
"moduleMigration.optionManualSetup": "모든 것을 수동으로 설정",
|
||||
"moduleMigration.optionNoAskAgain": "아니요 (나중에 다시 물어보기)",
|
||||
"moduleMigration.optionNoSetupUri": "아니요, 없습니다",
|
||||
"moduleMigration.optionRemindNextLaunch": "다음 시작 시 알림",
|
||||
"moduleMigration.optionSetupViaP2P": "%{short_p2p_sync}를 사용하여 설정",
|
||||
"moduleMigration.optionSetupWizard": "설정 마법사로 안내",
|
||||
"moduleMigration.optionYesFetchAgain": "예 (다시 가져오기)",
|
||||
"moduleMigration.titleCaseSensitivity": "대소문자 구분",
|
||||
"moduleMigration.titleRecommendSetupUri": "Setup URI 사용 권장",
|
||||
"moduleMigration.titleWelcome": "Self-hosted LiveSync에 오신 것을 환영합니다",
|
||||
"moduleObsidianMenu.replicate": "복제",
|
||||
"More actions": "추가 작업",
|
||||
"Mostly Complete: Decision Required": "거의 완료: 결정이 필요합니다",
|
||||
@@ -564,12 +553,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "활성화",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "수정",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "알겠습니다. 업데이트했습니다.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "다음",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "시작",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "테스트",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "사용",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "가져오기",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "다음",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "기본값",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "이것은 Setup URI로 Self-hosted LiveSync를 설정하는 권장 방법입니다.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "새 기기 설정에 완벽합니다!",
|
||||
@@ -633,7 +620,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "chttpd.enable_cors 설정",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "종단 간 암호화와 경로 난독화를 활성화하는 것을 권장합니다. 정말로 암호화 없이 계속하시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "원격 서버에서 구성을 가져오시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "모든 작업이 완료되었습니다! 다른 기기를 설정하기 위해 Setup URI를 생성하시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "서버 설정이 영구적으로 저장되지 않는 환경(예: Docker에서 실행 중)에서는 이곳의 값들이 변경될 수 있습니다. 연결이 가능해지면 서버의 local.ini 파일에서 설정을 수동으로 업데이트해 주세요.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "암호화 패스프레이즈가 유효하지 않을 수 있습니다. 정말로 계속하시겠습니까?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "업그레이드 알림으로 여기에 오셨나요? 버전 기록을 검토해 주세요. 만족하신다면 버튼을 클릭하세요. 새로운 업데이트 시 다시 안내됩니다.",
|
||||
@@ -643,7 +629,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "경고: 이 기능은 아직 개발 중이므로 다음 사항을 유의해 주세요:\n- 추가 전용 구조로 동작합니다. 저장 용량을 줄이려면 재구축이 필요합니다.\n- 다소 불안정합니다.\n- 최초 동기화 시 모든 기록이 원격에서 전송됩니다. 데이터 사용량 제한과 느린 속도에 유의해 주세요.\n- 실시간 동기화는 변경분만 처리합니다.\n\n문제가 발생했거나 이 기능에 대한 아이디어가 있다면 GitHub에 이슈를 등록해 주세요.\n큰 관심에 깊이 감사드립니다.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "출처 확인: ${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "변경 사항을 적용하려면 데이터베이스를 재구축해야 합니다. 변경 사항을 적용할 방법을 선택해 주세요.\n\n<details>\n<summary>범례</summary>\n\n| 기호 | 의미 |\n|: ------ :| ------- |\n| ⇔ | 최신 상태 |\n| ⇄ | 양쪽을 맞추는 동기화 |\n| ⇐,⇒ | 덮어쓰기 전송 |\n| ⇠,⇢ | 반대편에서 덮어쓰기 전송 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n한눈에 보기: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\n이 기기의 기존 파일을 사용해 로컬과 원격 데이터베이스를 모두 재구축합니다.\n이 경우 다른 기기는 잠기며, 가져오기를 수행해야 합니다.\n## ${OPTION_FETCH}\n한눈에 보기: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\n로컬 데이터베이스를 초기화한 뒤, 원격 데이터베이스에서 가져온 데이터로 재구축합니다.\n원격 데이터베이스를 이미 재구축한 경우도 여기에 해당합니다.\n## ${OPTION_ONLY_SETTING}\n설정만 저장합니다. **주의: 데이터가 손상될 수 있습니다.** 일반적으로는 데이터베이스 재구축이 필요합니다.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "마법사를 완료하려면 프리셋 항목을 선택하고 적용해 주세요.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "cors.credentials 설정",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "cors.origins 설정",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "couchdb.max_document_size 설정",
|
||||
@@ -700,7 +685,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "활성 원격 서버",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "모양",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "충돌 해결",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "축하합니다!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "삭제 전파",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "암호화가 활성화되지 않음",
|
||||
|
||||
@@ -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 был создан скриптом установки сервера, его использование обеспечивает простую и безопасную настройку。",
|
||||
@@ -306,7 +306,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Экстренная остановка включена",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Ожидание готовности...",
|
||||
"moduleLog.showLog": "Показать лог",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Проверить позже",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "Исправлено, больше не спрашивать",
|
||||
"moduleMigration.fix0256.buttons.fix": "Исправить",
|
||||
@@ -328,26 +327,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "Не удалось получить удалённые настройки",
|
||||
"moduleMigration.logSetupCancelled": "Настройка отменена, Self-hosted LiveSync ожидает вашей настройки!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "Удалённая база данных, похоже, уже была мигрирована. Конфигурация этого устройства несовместима.",
|
||||
"moduleMigration.msgInitialSetup": "Ваше устройство ещё не настроено. У вас есть Setup URI?",
|
||||
"moduleMigration.msgRecommendSetupUri": "Мы рекомендуем сгенерировать Setup URI.",
|
||||
"moduleMigration.msgSinceV02321": "Начиная с v0.23.21, self-hosted LiveSync изменил поведение и структуру базы данных.",
|
||||
"moduleMigration.optionAdjustRemote": "Настроить под удалённую",
|
||||
"moduleMigration.optionDecideLater": "Решить позже",
|
||||
"moduleMigration.optionEnableBoth": "Включить оба",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "Включить только #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "Включить только #2",
|
||||
"moduleMigration.optionHaveSetupUri": "Да, есть",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "Сохранить предыдущее поведение",
|
||||
"moduleMigration.optionManualSetup": "Настроить всё вручную",
|
||||
"moduleMigration.optionNoAskAgain": "Нет, спросить снова",
|
||||
"moduleMigration.optionNoSetupUri": "Нет, нет",
|
||||
"moduleMigration.optionRemindNextLaunch": "Напомнить при следующем запуске",
|
||||
"moduleMigration.optionSetupViaP2P": "Использовать short_p2p_sync для настройки",
|
||||
"moduleMigration.optionSetupWizard": "Перейти в мастер настройки",
|
||||
"moduleMigration.optionYesFetchAgain": "Да, загрузить снова",
|
||||
"moduleMigration.titleCaseSensitivity": "Чувствительность к регистру",
|
||||
"moduleMigration.titleRecommendSetupUri": "Рекомендация использовать Setup URI",
|
||||
"moduleMigration.titleWelcome": "Добро пожаловать в Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Реплицировать",
|
||||
"More actions": "Другие действия",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "Перемещать удалённые на удалённом сервере файлы в корзину вместо удаления.",
|
||||
@@ -377,12 +366,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "Включить",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "Исправить",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "Понял и обновил.",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "Далее",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "Старт",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "Тест",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "Использовать",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "Загрузить",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "Далее",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "По умолчанию",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "Это рекомендуемый способ настройки Self-hosted LiveSync с помощью Setup URI.",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "Идеально подходит для настройки нового устройства!",
|
||||
@@ -445,7 +432,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Установить chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Мы рекомендуем включить сквозное шифрование. Вы уверены, что хотите продолжить без шифрования?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "Вы хотите загрузить конфигурацию с удалённого сервера?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "Всё готово! Вы хотите сгенерировать Setup URI для настройки других устройств?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Если конфигурация сервера непостоянна, значения здесь могут измениться.",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Ваша парольная фраза шифрования может быть недействительна.",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "Вы пришли из-за уведомления об обновлении? Просмотрите историю версий.",
|
||||
@@ -455,7 +441,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "ПРЕДУПРЕЖДЕНИЕ: Эта функция в разработке.",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "Проверка origin: org",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "Требуется перестроение баз данных для применения изменений.",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "Выберите и примените любой пресет для завершения мастера.",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "Установить cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "Установить cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "Установить couchdb.max_document_size",
|
||||
@@ -512,7 +497,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Активный удалённый сервер",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "Внешний вид",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "Разрешение конфликтов",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "Поздравляем!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "Сервер CouchDB",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "Распространение удалений",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "Шифрование не включено",
|
||||
|
||||
@@ -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": "↓:覆寫本機",
|
||||
@@ -498,26 +498,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "無法取得遠端調校值",
|
||||
"moduleMigration.logSetupCancelled": "設定已取消,Self-hosted LiveSync 正在等候你完成設定!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "你可能已經知道,Self-hosted LiveSync 變更了預設行為與資料庫結構。\n\n值得慶幸的是,在你的努力下,遠端資料庫似乎已經完成遷移。恭喜!\n\n不過,還需要再多做一點。此裝置的設定與遠端資料庫不相容,我們需要再次從遠端資料庫抓取。現在要再次從遠端抓取嗎?\n\n___注意:在變更設定並再次抓取資料庫之前,我們無法進行同步。___\n___注意 2:chunks 完全不可變,我們只需要抓取中繼資料與差異部分。___",
|
||||
"moduleMigration.msgInitialSetup": "你的裝置**尚未完成設定**。讓我引導你完成設定流程。\n\n請記得,每個對話框的內容都可以複製到剪貼簿。如果日後需要參考,可以貼到 Obsidian 的筆記中,也可以用翻譯工具翻成你的語言。\n\n首先,你有 **Setup URI** 嗎?\n\n注意:如果你不知道那是什麼,請參閱[說明文件](${URI_DOC})。",
|
||||
"moduleMigration.msgRecommendSetupUri": "我們強烈建議你產生 Setup URI 並使用它。\n如果你對此不熟悉,請參閱[說明文件](${URI_DOC})(再次抱歉,但這很重要)。\n\n你想要如何手動設定?",
|
||||
"moduleMigration.msgSinceV02321": "自 v0.23.21 起,Self-hosted LiveSync 變更了預設行為與資料庫結構,進行了以下變更:\n\n1. **檔名的大小寫敏感性**\n 現在處理檔名時不區分大小寫。這對多數平台是有益的變更,除了 Linux 與 iOS,它們無法有效處理檔名的大小寫敏感性。\n (在這些平台上,檔名相同但大小寫不同的檔案會顯示警告。)\n\n2. **chunks 的版本處理**\n chunks 是不可變的,因此可以固定其版本。這項變更會提升檔案儲存的效能。\n\n___不過,若要啟用這兩項變更中的任何一項,都需要重建遠端與本機資料庫。這個過程需要幾分鐘,建議在時間充裕時進行。___\n\n- 如果你想維持先前的行為,可以使用 `${KEEP}` 略過此程序。\n- 如果你目前沒有足夠的時間,請選擇 `${DISMISS}`,之後會再次提示你。\n- 如果你已在其他裝置上重建過資料庫,請選擇 `${DISMISS}` 並再次嘗試同步;由於偵測到差異,系統會再次提示你。",
|
||||
"moduleMigration.optionAdjustRemote": "調整為遠端設定",
|
||||
"moduleMigration.optionDecideLater": "稍後再決定",
|
||||
"moduleMigration.optionEnableBoth": "兩者都啟用",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "僅啟用",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "僅啟用",
|
||||
"moduleMigration.optionHaveSetupUri": "有,我有",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "保留先前的行為",
|
||||
"moduleMigration.optionManualSetup": "全部手動設定",
|
||||
"moduleMigration.optionNoAskAgain": "不,請稍後再問我",
|
||||
"moduleMigration.optionNoSetupUri": "沒有,我沒有",
|
||||
"moduleMigration.optionRemindNextLaunch": "下次啟動時提醒我",
|
||||
"moduleMigration.optionSetupViaP2P": "使用「%{short_p2p_sync}」進行設定",
|
||||
"moduleMigration.optionSetupWizard": "帶我進入設定精靈",
|
||||
"moduleMigration.optionYesFetchAgain": "是,再次抓取",
|
||||
"moduleMigration.titleCaseSensitivity": "大小寫敏感性",
|
||||
"moduleMigration.titleRecommendSetupUri": "建議使用 Setup URI",
|
||||
"moduleMigration.titleWelcome": "歡迎使用 Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "複寫",
|
||||
"More actions": "更多操作",
|
||||
"Mostly Complete: Decision Required": "大致完成:需要你做出決定",
|
||||
@@ -562,12 +552,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "啟用",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "修正",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "我知道了,且已更新。",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "下一步",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "開始",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "測試",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "使用",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "抓取",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "下一步",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "預設語言",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "這是使用 Setup URI 設定 Self-hosted LiveSync 的建議方式。",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "設定新裝置時非常方便!",
|
||||
@@ -629,7 +617,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "設定 chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "我們建議啟用端對端加密與路徑混淆。你確定要在未加密的情況下繼續嗎?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "要從遠端伺服器抓取設定嗎?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "全部完成!要產生 Setup URI 以便設定其他裝置嗎?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "如果伺服器設定並非持久保存(例如在 Docker 上執行),這裡的值可能會變動。一旦能夠連線,請更新伺服器 local.ini 中的設定。",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "你的加密密語可能無效。你確定要繼續嗎?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "因為升級通知才來到這裡嗎?請查看版本歷程。若你已確認無誤,請點選按鈕。下次更新時會再次提示。",
|
||||
@@ -639,7 +626,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "警告:此功能仍在開發中,請注意以下事項:\n- 僅附加架構。需要重建才能縮小儲存空間。\n- 較不穩定。\n- 首次同步時,所有歷史記錄都會從遠端傳輸,請留意流量上限與速度較慢的情況。\n- 之後只會即時同步差異部分。\n\n如果你遇到任何問題,或對此功能有任何想法,請到 GitHub 建立 issue。\n感謝你的大力支持。",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "來源檢查:${org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "需要重建資料庫才能套用變更。請選擇套用變更的方式。\n\n<details>\n<summary>圖例</summary>\n\n| 符號 | 含意 |\n|: ------ :| ------- |\n| ⇔ | 已是最新 |\n| ⇄ | 同步以取得一致 |\n| ⇐,⇒ | 傳輸並覆寫 |\n| ⇠,⇢ | 從另一端傳輸並覆寫 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n概覽: 📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\n以此裝置上現有的檔案重建本機與遠端資料庫。\n這會鎖定其他裝置,它們必須執行抓取。\n## ${OPTION_FETCH}\n概覽: 📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\n初始化本機資料庫,並以從遠端資料庫抓取的資料重建。\n這也涵蓋你已重建遠端資料庫的情況。\n## ${OPTION_ONLY_SETTING}\n僅儲存設定。**注意:這可能導致資料損毀**;通常仍需要重建資料庫。",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "請選擇並套用任一預設項目以完成精靈。",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "設定 cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "設定 cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "設定 couchdb.max_document_size",
|
||||
@@ -696,7 +682,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "使用中的遠端伺服器",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "外觀",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "衝突處理",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "恭喜!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB 伺服器",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "刪除傳播",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "尚未啟用加密",
|
||||
|
||||
@@ -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,使用它可以更简单且更安全地完成配置。",
|
||||
@@ -293,7 +293,6 @@
|
||||
"moduleLiveSyncMain.titleScramEnabled": "紧急停止已启用",
|
||||
"moduleLocalDatabase.logWaitingForReady": "等待就绪...",
|
||||
"moduleLog.showLog": "显示日志",
|
||||
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/zh/README_zh.md#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "稍后检查",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "我已经修复了,不再询问",
|
||||
"moduleMigration.fix0256.buttons.fix": "修复",
|
||||
@@ -315,26 +314,16 @@
|
||||
"moduleMigration.logRemoteTweakUnavailable": "无法获取远程调整值",
|
||||
"moduleMigration.logSetupCancelled": "设置已取消,Self-hosted LiveSync 正在等待您的设置!",
|
||||
"moduleMigration.msgFetchRemoteAgain": "您可能已经知道,Self-hosted LiveSync 更改了其默认行为和数据库结构。\n\n值得庆幸的是,在您的时间和努力下,远程数据库似乎已经迁移完成。恭喜!\n\n但是,我们还需要一点点操作。此设备的配置与远程数据库不兼容。我们需要再次从远程数据库获取。我们现在应该再次从远程获取吗?\n\n___注意:在更改配置并再次获取数据库之前,我们无法进行同步。___\n___注意2:chunks 是完全不可变的,我们只能获取元数据和差异",
|
||||
"moduleMigration.msgInitialSetup": "您的设备**尚未设置**。让我引导您完成设置过程。\n\n请记住,每个对话框内容都可以复制到剪贴板。如果以后需要参考,可以将其粘贴到 Obsidian 的笔记中。您也可以使用翻译工具将其翻译成您的语言。\n\n首先,您有**设置 URI** 吗?\n\n注意:如果您不知道这是什么,请参阅[文档](${URI_DOC})",
|
||||
"moduleMigration.msgRecommendSetupUri": "我们强烈建议您生成一个设置 URI 并使用它。\n如果您对此不了解,请参阅[文档](${URI_DOC})(再次抱歉,但这很重要)。\n\n您想如何手动设置?",
|
||||
"moduleMigration.msgSinceV02321": "自 v0.23.21 起,Self-hosted LiveSync 更改了默认行为和数据库结构。进行了以下更改:\n\n1. **文件名的区分大小写**\n现在处理文件名时不区分大小写。这对于大多数平台来说是一个有益的更改,除了 Linux 和 iOS,它们不能有效地管理文件名的大小写敏感性。\n(在这些平台上,对于名称相同但大小写不同的文件将显示警告)。\n\n2. **chunks 的版本处理**\nchunks 是不可变的,这使得它们的版本可以固定。此更改将提高文件保存的性能。\n\n___然而,要启用这些更改中的任何一个,都需要重建远程和本地数据库。这个过程需要几分钟,我们建议您在有充足时间时进行。___\n\n- 如果您希望保持以前的行为,可以使用 `${KEEP}` 跳过此过程。\n- 如果您没有足够的时间,请选择 `${DISMISS}`。稍后会再次提示您。\n- 如果您已在另一台设备上重建了数据库,请选择 `${DISMISS}` 并尝试再次同步。由于检测到差异,系统会再次提示您",
|
||||
"moduleMigration.optionAdjustRemote": "调整到远程设置",
|
||||
"moduleMigration.optionDecideLater": "稍后决定",
|
||||
"moduleMigration.optionEnableBoth": "启用两者",
|
||||
"moduleMigration.optionEnableFilenameCaseInsensitive": "仅启用 #1",
|
||||
"moduleMigration.optionEnableFixedRevisionForChunks": "仅启用 #2",
|
||||
"moduleMigration.optionHaveSetupUri": "是的,我有",
|
||||
"moduleMigration.optionKeepPreviousBehaviour": "保持以前的行为",
|
||||
"moduleMigration.optionManualSetup": "全部手动设置",
|
||||
"moduleMigration.optionNoAskAgain": "不,请稍后再次询问",
|
||||
"moduleMigration.optionNoSetupUri": "不,我没有",
|
||||
"moduleMigration.optionRemindNextLaunch": "下次启动时提醒我",
|
||||
"moduleMigration.optionSetupViaP2P": "Use %{short_p2p_sync} to set up",
|
||||
"moduleMigration.optionSetupWizard": "带我进入设置向导",
|
||||
"moduleMigration.optionYesFetchAgain": "是的,再次获取",
|
||||
"moduleMigration.titleCaseSensitivity": "大小写敏感性",
|
||||
"moduleMigration.titleRecommendSetupUri": "推荐使用设置 URI",
|
||||
"moduleMigration.titleWelcome": "欢迎使用 Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "复制",
|
||||
"More actions": "更多操作",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "将远程删除的文件移至回收站,而不是直接删除",
|
||||
@@ -363,12 +352,10 @@
|
||||
"obsidianLiveSyncSettingTab.btnEnable": "启用",
|
||||
"obsidianLiveSyncSettingTab.btnFix": "修复",
|
||||
"obsidianLiveSyncSettingTab.btnGotItAndUpdated": "我明白了并且已更新",
|
||||
"obsidianLiveSyncSettingTab.btnNext": "下一步",
|
||||
"obsidianLiveSyncSettingTab.btnStart": "开始",
|
||||
"obsidianLiveSyncSettingTab.btnTest": "测试",
|
||||
"obsidianLiveSyncSettingTab.btnUse": "使用",
|
||||
"obsidianLiveSyncSettingTab.buttonFetch": "获取",
|
||||
"obsidianLiveSyncSettingTab.buttonNext": "下一步",
|
||||
"obsidianLiveSyncSettingTab.defaultLanguage": "默认语言",
|
||||
"obsidianLiveSyncSettingTab.descConnectSetupURI": "这是使用设置 URI 设置 Self-hosted LiveSync 的推荐方法",
|
||||
"obsidianLiveSyncSettingTab.descCopySetupURI": "非常适合设置新设备!",
|
||||
@@ -431,7 +418,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "设置 chttpd.enable_cors",
|
||||
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "建议启用端到端加密和路径混淆。你确定要在未加密的情况下继续吗?",
|
||||
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "要从远端服务器获取配置吗?",
|
||||
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "全部完成!要生成设置 URI 以便配置其他设备吗?",
|
||||
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "如果服务器配置不是持久的(例如,在 docker 上运行),此处的值可能会更改。一旦能够连接,请更新服务器 local.ini 中的设置",
|
||||
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "你的加密密码短语可能无效。你确定要继续吗?",
|
||||
"obsidianLiveSyncSettingTab.msgNewVersionNote": "因为升级通知来到这里?请查看版本历史。如果您满意,请点击按钮。新的更新将再次提示此信息",
|
||||
@@ -441,7 +427,6 @@
|
||||
"obsidianLiveSyncSettingTab.msgObjectStorageWarning": "警告:此功能仍在开发中,请注意以下几点:\n- 仅追加架构。需要重建才能缩小存储空间。\n- 有点脆弱。\n- 首次同步时,所有历史记录将从远程传输。注意数据上限和慢速。\n- 只有差异会实时同步。\n\n如果您遇到任何问题,或对此功能有任何想法,请在 GitHub 上创建 issue。\n感谢您的巨大贡献",
|
||||
"obsidianLiveSyncSettingTab.msgOriginCheck": "源检查: {org}",
|
||||
"obsidianLiveSyncSettingTab.msgRebuildRequired": "需要重建数据库以应用更改。请选择应用更改的方法。\n\n<details>\n<summary>图例</summary>\n\n| 符号 | 含义 |\n|: ------ :| ------- |\n| ⇔ | 最新 |\n| ⇄ | 同步以平衡 |\n| ⇐,⇒ | 传输以覆盖 |\n| ⇠,⇢ | 从另一侧传输以覆盖 |\n\n</details>\n\n## ${OPTION_REBUILD_BOTH}\n概览:📄 ⇒¹ 💻 ⇒² 🛰️ ⇢ⁿ 💻 ⇄ⁿ⁺¹ 📄\n使用此设备的现有文件重建本地和远程数据库。\n这将导致其他设备被锁定,并且它们需要执行获取操作。\n## ${OPTION_FETCH}\n概览:📄 ⇄² 💻 ⇐¹ 🛰️ ⇔ 💻 ⇔ 📄\n初始化本地数据库并使用从远程数据库获取的数据重建它。\n这种情况包括您已经重建了远程数据库的情况。\n## ${OPTION_ONLY_SETTING}\n仅存储设置。**注意:这可能导致数据损坏**;通常需要重建数据库",
|
||||
"obsidianLiveSyncSettingTab.msgSelectAndApplyPreset": "请选择并应用任一预设项以完成向导。",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsCredentials": "设置 cors.credentials",
|
||||
"obsidianLiveSyncSettingTab.msgSetCorsOrigins": "设置 cors.origins",
|
||||
"obsidianLiveSyncSettingTab.msgSetMaxDocSize": "设置 couchdb.max_document_size",
|
||||
@@ -498,7 +483,6 @@
|
||||
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "活动远程服务器",
|
||||
"obsidianLiveSyncSettingTab.titleAppearance": "外观",
|
||||
"obsidianLiveSyncSettingTab.titleConflictResolution": "冲突处理",
|
||||
"obsidianLiveSyncSettingTab.titleCongratulations": "恭喜!",
|
||||
"obsidianLiveSyncSettingTab.titleCouchDB": "CouchDB 服务器",
|
||||
"obsidianLiveSyncSettingTab.titleDeletionPropagation": "删除传播",
|
||||
"obsidianLiveSyncSettingTab.titleEncryptionNotEnabled": "尚未启用加密",
|
||||
|
||||
@@ -92,8 +92,6 @@ Normal Files: Normale Dateien
|
||||
obsidianLiveSyncSettingTab:
|
||||
btnApply: Anwenden
|
||||
btnDisable: Deaktivieren
|
||||
btnNext: Weiter
|
||||
buttonNext: Weiter
|
||||
defaultLanguage: Standardsprache
|
||||
labelDisabled: "⏹️ : Deaktiviert"
|
||||
labelEnabled: "🔁 : Aktiviert"
|
||||
@@ -106,12 +104,8 @@ obsidianLiveSyncSettingTab:
|
||||
und Pfadverschleierung zu aktivieren. Möchten Sie wirklich ohne
|
||||
Verschlüsselung fortfahren?
|
||||
msgFetchConfigFromRemote: Möchten Sie die Konfiguration vom Remote-Server abrufen?
|
||||
msgGenerateSetupURI: Alles fertig! Möchten Sie eine Setup-URI erzeugen, um
|
||||
andere Geräte einzurichten?
|
||||
msgInvalidPassphrase: Ihre Verschlüsselungs-Passphrase könnte ungültig sein.
|
||||
Möchten Sie wirklich fortfahren?
|
||||
msgSelectAndApplyPreset: Bitte wählen und übernehmen Sie eine beliebige
|
||||
Voreinstellung, um den Assistenten abzuschließen.
|
||||
nameDisableHiddenFileSync: Synchronisation versteckter Dateien deaktivieren
|
||||
nameEnableHiddenFileSync: Synchronisation versteckter Dateien aktivieren
|
||||
nameHiddenFileSynchronization: Synchronisation versteckter Dateien
|
||||
@@ -122,7 +116,6 @@ obsidianLiveSyncSettingTab:
|
||||
optionPeriodicWithBatch: Periodisch mit Stapelverarbeitung
|
||||
titleAppearance: Darstellung
|
||||
titleConflictResolution: Konfliktbehandlung
|
||||
titleCongratulations: Glückwunsch!
|
||||
titleCouchDB: CouchDB-Server
|
||||
titleDeletionPropagation: Weitergabe von Löschungen
|
||||
titleEncryptionNotEnabled: Verschlüsselung ist nicht aktiviert
|
||||
@@ -290,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
|
||||
@@ -362,6 +369,7 @@ Export: Export
|
||||
"Failed to connect to the server: ${reason}": "Failed to connect to the server: ${reason}"
|
||||
Failed to connect to the server. Please check your settings.: Failed to connect to the server. Please check your settings.
|
||||
"Failed to connect to the signalling relay: ${reason}": "Failed to connect to the signalling relay: ${reason}"
|
||||
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.
|
||||
Failed to create replicator instance.: Failed to create replicator instance.
|
||||
Failed to parse Setup-URI.: Failed to parse Setup-URI.
|
||||
"Failed:": "Failed:"
|
||||
@@ -401,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.
|
||||
@@ -725,7 +734,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Show Log
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: Check it later
|
||||
@@ -819,30 +827,6 @@ moduleMigration:
|
||||
|
||||
___Note2: The chunks are completely immutable, we can fetch only the
|
||||
metadata and difference.___
|
||||
msgInitialSetup: >-
|
||||
Your device has **not been set up yet**. Let me guide you through the setup
|
||||
process.
|
||||
|
||||
|
||||
Please keep in mind that every dialogue content can be copied to the
|
||||
clipboard. If you need to refer to it later, you can paste it into a note in
|
||||
Obsidian. You can also translate it into your language using a translation
|
||||
tool.
|
||||
|
||||
|
||||
First, do you have **Setup URI**?
|
||||
|
||||
|
||||
Note: If you do not know what it is, please refer to the
|
||||
[documentation](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
We strongly recommend that you generate a set-up URI and use it.
|
||||
|
||||
If you do not have knowledge about it, please refer to the
|
||||
[documentation](${URI_DOC}) (Sorry again, but it is important).
|
||||
|
||||
|
||||
How do you want to set it up manually?
|
||||
msgSinceV02321: >-
|
||||
Since v0.23.21, the self-hosted LiveSync has changed the default behaviour
|
||||
and database structure. The following changes have been made:
|
||||
@@ -874,18 +858,10 @@ moduleMigration:
|
||||
optionEnableBoth: Enable both
|
||||
optionEnableFilenameCaseInsensitive: "Enable only #1"
|
||||
optionEnableFixedRevisionForChunks: "Enable only #2"
|
||||
optionHaveSetupUri: Yes, I have
|
||||
optionKeepPreviousBehaviour: Keep previous behaviour
|
||||
optionManualSetup: Set it up all manually
|
||||
optionNoAskAgain: No, please ask again
|
||||
optionNoSetupUri: No, I do not have
|
||||
optionRemindNextLaunch: Remind me at the next launch
|
||||
optionSetupViaP2P: Use %{short_p2p_sync} to set up
|
||||
optionSetupWizard: Take me into the setup wizard
|
||||
optionYesFetchAgain: Yes, fetch again
|
||||
titleCaseSensitivity: Case Sensitivity
|
||||
titleRecommendSetupUri: Recommendation to use Setup URI
|
||||
titleWelcome: Welcome to Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: Replicate
|
||||
More actions: More actions
|
||||
@@ -940,12 +916,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Enable
|
||||
btnFix: Fix
|
||||
btnGotItAndUpdated: I got it and updated.
|
||||
btnNext: Next
|
||||
btnStart: Start
|
||||
btnTest: Test
|
||||
btnUse: Use
|
||||
buttonFetch: Fetch
|
||||
buttonNext: Next
|
||||
defaultLanguage: Default
|
||||
descConnectSetupURI: This is the recommended method to set up Self-hosted
|
||||
LiveSync with a Setup URI.
|
||||
@@ -1018,7 +992,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableEncryptionRecommendation: We recommend enabling End-To-End Encryption,
|
||||
and Path Obfuscation. Are you sure you want to continue without encryption?
|
||||
msgFetchConfigFromRemote: Do you want to fetch the config from the remote server?
|
||||
msgGenerateSetupURI: All done! Do you want to generate a setup URI to set up other devices?
|
||||
msgIfConfigNotPersistent: If the server configuration is not persistent (e.g.,
|
||||
running on docker), the values here may change. Once you are able to
|
||||
connect, please update the settings in the server's local.ini.
|
||||
@@ -1098,7 +1071,6 @@ obsidianLiveSyncSettingTab:
|
||||
|
||||
Store only the settings. **Caution: This may lead to data corruption**;
|
||||
database reconstruction is generally necessary.
|
||||
msgSelectAndApplyPreset: Please select and apply any preset item to complete the wizard.
|
||||
msgSetCorsCredentials: Set cors.credentials
|
||||
msgSetCorsOrigins: Set cors.origins
|
||||
msgSetMaxDocSize: Set couchdb.max_document_size
|
||||
@@ -1158,12 +1130,17 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: Active Remote Server
|
||||
titleAppearance: Appearance
|
||||
titleConflictResolution: Conflict resolution
|
||||
titleCongratulations: Congratulations!
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: Deletion Propagation
|
||||
titleEncryptionNotEnabled: Encryption is not enabled
|
||||
titleEncryptionPassphraseInvalid: Encryption Passphrase Invalid
|
||||
titleExtraFeatures: Enable extra and advanced features
|
||||
titleExtraFeaturesGroup: Extra features
|
||||
titleExtraMenus: Extra menus
|
||||
titleHelpAndInformation: Help and information
|
||||
titleHelpAndTroubleshooting: Help and troubleshooting
|
||||
titleMaintenanceAndRecovery: Maintenance and recovery
|
||||
titleAdvancedSettings: Advanced settings
|
||||
titleFetchConfig: Fetch Config
|
||||
titleFetchConfigFromRemote: Fetch config from remote server
|
||||
titleFetchSettings: Fetch Settings
|
||||
@@ -1177,7 +1154,8 @@ obsidianLiveSyncSettingTab:
|
||||
titleRemoteConfigCheckFailed: Remote Configuration Check Failed
|
||||
titleRemoteServer: Remote Server
|
||||
titleReset: Reset
|
||||
titleSetupOtherDevices: To setup other devices
|
||||
titleSetupOtherDevices: Set up other devices
|
||||
titleSynchronisation: Synchronisation
|
||||
titleSynchronizationMethod: Synchronization Method
|
||||
titleSynchronizationPreset: Synchronization Preset
|
||||
titleSyncSettings: Sync Settings
|
||||
@@ -1469,8 +1447,8 @@ Replicator:
|
||||
InitialiseFatalError: No replicator is available, this is the fatal error.
|
||||
Pending: Some file events are pending. Replication has been cancelled.
|
||||
SomeModuleFailed: Replication has been cancelled by some module failure
|
||||
VersionUpFlash: An update has been detected. Please open the Settings dialogue
|
||||
and check the Change Log. Replication has been cancelled.
|
||||
VersionUpFlash: Remote synchronisation is paused for compatibility review. Run the
|
||||
'Review why synchronisation is paused' command for details and available actions.
|
||||
Requires restart of Obsidian: Requires restart of Obsidian
|
||||
Requires restart of Obsidian.: Requires restart of Obsidian.
|
||||
Rerun Onboarding Wizard: Rerun Onboarding Wizard
|
||||
@@ -1518,7 +1496,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
|
||||
@@ -1780,6 +1762,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,
|
||||
@@ -1830,6 +1813,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
|
||||
@@ -1906,9 +1890,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.
|
||||
@@ -2101,7 +2093,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"
|
||||
@@ -2340,6 +2332,29 @@ Ui:
|
||||
Fetch: Fetch
|
||||
Overwrite: Overwrite
|
||||
SetupWizard:
|
||||
ApplySettingsInitialisation:
|
||||
ApplyWithoutInitialisation: Apply without Initialisation
|
||||
Back: Review another way to apply these settings
|
||||
BypassGuidance: Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.
|
||||
BypassTitle: Apply Settings without Initialisation?
|
||||
ContinueFetch: Continue with Fetch
|
||||
FetchOption: Reset Synchronisation on This Device
|
||||
FetchOptionDesc: After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.
|
||||
FetchOptionP2PDesc: After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.
|
||||
Guidance: These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.
|
||||
KeepEditing: Keep Editing
|
||||
ProceedFetch: Restart and Fetch Synchronisation Data
|
||||
ProceedFetchP2P: Restart and Select a Source Device
|
||||
ProceedRebuild: Restart and Overwrite Server Data
|
||||
ProceedRebuildP2P: Restart and Prepare This Device
|
||||
Question: Which existing data should be used after restart?
|
||||
RebuildOption: Overwrite Server Data with This Device's Files
|
||||
RebuildOptionDesc: Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.
|
||||
RebuildOptionP2P: Prepare This Device from This Vault
|
||||
RebuildOptionP2PDesc: Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.
|
||||
RemoteVerificationGuidance: The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.
|
||||
RemoteVerificationTitle: Remote Synchronisation Data Could Not Be Verified
|
||||
Title: Apply Settings and Reinitialise Synchronisation
|
||||
Common:
|
||||
Back: No, please take me back
|
||||
Cancel: Cancel
|
||||
@@ -2426,6 +2441,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.:
|
||||
@@ -751,7 +759,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Mostrar registro
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use
|
||||
logBulkSendCorrupted: El envío de fragmentos en bloque se ha habilitado, sin
|
||||
embargo, esta función se ha corrompido. Disculpe las molestias.
|
||||
Deshabilitado automáticamente.
|
||||
@@ -782,30 +789,6 @@ moduleMigration:
|
||||
|
||||
___Nota2: Los fragmentos son completamente inmutables, solo podemos obtener
|
||||
los metadatos y diferencias.___
|
||||
msgInitialSetup: >-
|
||||
Tu dispositivo **aún no ha sido configurado**. Permíteme guiarte a través
|
||||
del proceso de configuración.
|
||||
|
||||
|
||||
Ten en cuenta que todo el contenido del diálogo se puede copiar al
|
||||
portapapeles. Si necesitas consultarlo más tarde, puedes pegarlo en una nota
|
||||
en Obsidian. También puedes traducirlo a tu idioma utilizando una
|
||||
herramienta de traducción.
|
||||
|
||||
|
||||
Primero, ¿tienes **URI de configuración**?
|
||||
|
||||
|
||||
Nota: Si no sabes qué es, consulta la [documentación](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
Te recomendamos encarecidamente que generes una URI de configuración y la
|
||||
utilices.
|
||||
|
||||
Si no tienes conocimientos al respecto, consulta la
|
||||
[documentación](${URI_DOC}) (Lo siento de nuevo, pero es importante).
|
||||
|
||||
|
||||
¿Cómo quieres configurarlo manualmente?
|
||||
msgSinceV02321: >-
|
||||
Desde la versión v0.23.21, Self-hosted LiveSync ha cambiado el
|
||||
comportamiento predeterminado y la estructura de la base de datos. Se han
|
||||
@@ -838,9 +821,7 @@ moduleMigration:
|
||||
optionEnableBoth: Habilitar ambos
|
||||
optionEnableFilenameCaseInsensitive: "Habilitar solo #1"
|
||||
optionEnableFixedRevisionForChunks: "Habilitar solo #2"
|
||||
optionHaveSetupUri: Sí, tengo
|
||||
optionKeepPreviousBehaviour: Mantener comportamiento anterior
|
||||
optionManualSetup: Configurarlo todo manualmente
|
||||
optionNoAskAgain: No, por favor pregúntame de nuevo
|
||||
fix0256:
|
||||
buttons:
|
||||
@@ -909,14 +890,8 @@ moduleMigration:
|
||||
Nota 2: reconstruir todo y obtener los datos consume algo de tiempo y de
|
||||
tráfico; hazlo en horas de poco uso y con una conexión de red estable.
|
||||
title: ¡Se han encontrado chunks no seguros!
|
||||
optionNoSetupUri: No, no tengo
|
||||
optionRemindNextLaunch: Recordármelo en el próximo inicio
|
||||
optionSetupViaP2P: Usar %{short_p2p_sync} para configurarlo
|
||||
optionSetupWizard: Llévame al asistente de configuración
|
||||
optionYesFetchAgain: Sí, obtener de nuevo
|
||||
titleCaseSensitivity: Distinción de mayúsculas y minúsculas
|
||||
titleRecommendSetupUri: Recomendación de usar un Setup URI
|
||||
titleWelcome: Bienvenido a Self-hosted LiveSync
|
||||
"Mostly Complete: Decision Required": "Casi terminado: se requiere una decisión"
|
||||
My remote server is already set up. I want to join this device.: Mi servidor remoto ya está configurado. Quiero añadir este dispositivo.
|
||||
Name: Nombre
|
||||
@@ -1506,12 +1481,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Activar
|
||||
btnFix: Corregir
|
||||
btnGotItAndUpdated: Lo entendí y actualicé.
|
||||
btnNext: Siguiente
|
||||
btnStart: Iniciar
|
||||
btnTest: Probar
|
||||
btnUse: Usar
|
||||
buttonFetch: Obtener
|
||||
buttonNext: Siguiente
|
||||
defaultLanguage: Predeterminado
|
||||
descConnectSetupURI: Este es el método recomendado para configurar Self-hosted
|
||||
LiveSync con una URI de configuración.
|
||||
@@ -1553,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}"
|
||||
@@ -1585,8 +1558,6 @@ obsidianLiveSyncSettingTab:
|
||||
a extremo y la obfuscación de ruta. ¿Estás seguro de querer continuar sin
|
||||
cifrado?
|
||||
msgFetchConfigFromRemote: ¿Quieres obtener la configuración del servidor remoto?
|
||||
msgGenerateSetupURI: ¡Todo listo! ¿Quieres generar un URI de configuración para
|
||||
configurar otros dispositivos?
|
||||
msgIfConfigNotPersistent: Si la configuración del servidor no es persistente
|
||||
(por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una
|
||||
vez que puedas conectarte, por favor actualiza las configuraciones en el
|
||||
@@ -1670,8 +1641,6 @@ obsidianLiveSyncSettingTab:
|
||||
|
||||
Almacena solo la configuración. **Precaución: esto puede provocar corrupción
|
||||
de datos**; generalmente es necesario reconstruir la base de datos.
|
||||
msgSelectAndApplyPreset: Por favor, selecciona y aplica cualquier elemento
|
||||
preestablecido para completar el asistente.
|
||||
msgSetCorsCredentials: Configurar cors.credentials
|
||||
msgSetCorsOrigins: Configurar cors.origins
|
||||
msgSetMaxDocSize: Configurar couchdb.max_document_size
|
||||
@@ -1695,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
|
||||
@@ -1723,13 +1692,12 @@ 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
|
||||
titleConflictResolution: Resolución de conflictos
|
||||
titleCongratulations: ¡Felicidades!
|
||||
titleCouchDB: Servidor CouchDB
|
||||
titleDeletionPropagation: Propagación de eliminación
|
||||
titleEncryptionNotEnabled: El cifrado no está habilitado
|
||||
@@ -1751,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}"
|
||||
@@ -1844,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
|
||||
@@ -1950,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
|
||||
@@ -2000,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)
|
||||
@@ -2062,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:
|
||||
@@ -2650,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。"
|
||||
@@ -2706,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
|
||||
|
||||
@@ -384,7 +384,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Afficher le journal
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: Vérifier plus tard
|
||||
@@ -482,31 +481,6 @@ moduleMigration:
|
||||
|
||||
___Note 2 : Les fragments sont complètement immuables, nous ne pouvons
|
||||
récupérer que les métadonnées et les différences.___
|
||||
msgInitialSetup: >-
|
||||
Votre appareil n'a **pas encore été configuré**. Laissez-moi vous guider
|
||||
dans le processus de configuration.
|
||||
|
||||
|
||||
Veuillez noter que chaque contenu de boîte de dialogue peut être copié
|
||||
dans le presse-papiers. Si vous souhaitez vous y référer plus tard, vous
|
||||
pouvez le coller dans une note d'Obsidian. Vous pouvez également le
|
||||
traduire dans votre langue via un outil de traduction.
|
||||
|
||||
|
||||
Tout d'abord, disposez-vous d'une **URI de configuration** ?
|
||||
|
||||
|
||||
Note : Si vous ne savez pas ce que c'est, consultez la
|
||||
[documentation](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
Nous recommandons vivement de générer une URI de configuration et de
|
||||
l'utiliser.
|
||||
|
||||
Si vous ne connaissez pas, veuillez consulter la
|
||||
[documentation](${URI_DOC}) (Désolé encore, mais c'est important).
|
||||
|
||||
|
||||
Comment souhaitez-vous effectuer la configuration manuellement ?
|
||||
msgSinceV02321: >-
|
||||
Depuis la v0.23.21, Self-hosted LiveSync a modifié son comportement par
|
||||
défaut et la structure de sa base. Les changements suivants ont été
|
||||
@@ -539,18 +513,10 @@ moduleMigration:
|
||||
optionEnableBoth: Activer les deux
|
||||
optionEnableFilenameCaseInsensitive: "Activer seulement #1"
|
||||
optionEnableFixedRevisionForChunks: "Activer seulement #2"
|
||||
optionHaveSetupUri: Oui, j'en ai une
|
||||
optionKeepPreviousBehaviour: Conserver le comportement précédent
|
||||
optionManualSetup: Tout configurer manuellement
|
||||
optionNoAskAgain: Non, demandez à nouveau
|
||||
optionNoSetupUri: Non, je n'en ai pas
|
||||
optionRemindNextLaunch: Me rappeler au prochain lancement
|
||||
optionSetupViaP2P: Utiliser %{short_p2p_sync} pour configurer
|
||||
optionSetupWizard: Ouvrir l'assistant de configuration
|
||||
optionYesFetchAgain: Oui, récupérer à nouveau
|
||||
titleCaseSensitivity: Sensibilité à la casse
|
||||
titleRecommendSetupUri: Recommandation d'utilisation de l'URI de configuration
|
||||
titleWelcome: Bienvenue dans Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: Répliquer
|
||||
Move remotely deleted files to the trash, instead of deleting.: Déplacer les fichiers supprimés à distance vers la corbeille, au lieu de les supprimer.
|
||||
@@ -574,12 +540,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Activer
|
||||
btnFix: Corriger
|
||||
btnGotItAndUpdated: J'ai compris et mis à jour.
|
||||
btnNext: Suivant
|
||||
btnStart: Démarrer
|
||||
btnTest: Tester
|
||||
btnUse: Utiliser
|
||||
buttonFetch: Récupérer
|
||||
buttonNext: Suivant
|
||||
defaultLanguage: Par défaut
|
||||
descConnectSetupURI: Méthode recommandée pour configurer Self-hosted LiveSync
|
||||
avec une URI de configuration.
|
||||
@@ -653,7 +617,6 @@ obsidianLiveSyncSettingTab:
|
||||
de bout en bout et l'obfuscation des chemins. Êtes-vous sûr de vouloir
|
||||
continuer sans chiffrement ?
|
||||
msgFetchConfigFromRemote: Voulez-vous récupérer la configuration depuis le serveur distant ?
|
||||
msgGenerateSetupURI: Tout est prêt ! Voulez-vous générer une URI de configuration pour configurer d'autres appareils ?
|
||||
msgIfConfigNotPersistent: Si la configuration du serveur n'est pas persistante
|
||||
(par ex. fonctionnant sur Docker), les valeurs peuvent changer. Une fois la
|
||||
connexion établie, mettez à jour les paramètres dans le local.ini du
|
||||
@@ -736,7 +699,6 @@ obsidianLiveSyncSettingTab:
|
||||
Ne stocker que les paramètres. **Attention : cela peut entraîner une
|
||||
corruption des données** ; une reconstruction de la base est généralement
|
||||
nécessaire.
|
||||
msgSelectAndApplyPreset: Veuillez sélectionner et appliquer un préréglage pour terminer l'assistant.
|
||||
msgSetCorsCredentials: Définir cors.credentials
|
||||
msgSetCorsOrigins: Définir cors.origins
|
||||
msgSetMaxDocSize: Définir couchdb.max_document_size
|
||||
@@ -797,7 +759,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: Serveur distant actif
|
||||
titleAppearance: Apparence
|
||||
titleConflictResolution: Résolution des conflits
|
||||
titleCongratulations: Félicitations !
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: Propagation des suppressions
|
||||
titleEncryptionNotEnabled: Le chiffrement n'est pas activé
|
||||
|
||||
@@ -344,7 +344,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: הצג יומן
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: בדוק מאוחר יותר
|
||||
@@ -430,25 +429,6 @@ moduleMigration:
|
||||
|
||||
___הערה 2: הנתחים הם בלתי-ניתנים לשינוי לחלוטין, ניתן למשוך רק את המטה-נתונים
|
||||
וההפרש.___
|
||||
msgInitialSetup: >-
|
||||
המכשיר שלך **טרם הוגדר**. אנחנו כאן לעזור לך בתהליך ההגדרה.
|
||||
|
||||
|
||||
שים לב שניתן להעתיק את תוכן כל דיאלוג ללוח. אם צריך לחזור אליו מאוחר יותר,
|
||||
ניתן להדביק אותו כפתק ב-Obsidian. ניתן גם לתרגם לשפתך בעזרת כלי תרגום.
|
||||
|
||||
|
||||
ראשית, האם יש לך **Setup URI**?
|
||||
|
||||
|
||||
הערה: אם אינך יודע מהו, אנא עיין ב[תיעוד](${URI_DOC}).
|
||||
msgRecommendSetupUri: >-
|
||||
אנו ממליצים בחום לייצר Setup URI ולהשתמש בו.
|
||||
|
||||
אם אין לך ידע בנושא, אנא עיין ב[תיעוד](${URI_DOC}) (מתנצלים שוב, אך זה חשוב).
|
||||
|
||||
|
||||
כיצד ברצונך להגדיר ידנית?
|
||||
msgSinceV02321: >-
|
||||
מאז גרסה 0.23.21, Self-hosted LiveSync שינה את התנהגות ברירת המחדל ומבנה מסד
|
||||
הנתונים. השינויים הבאים בוצעו:
|
||||
@@ -478,18 +458,10 @@ moduleMigration:
|
||||
optionEnableBoth: הפעל את שניהם
|
||||
optionEnableFilenameCaseInsensitive: "הפעל רק #1"
|
||||
optionEnableFixedRevisionForChunks: "הפעל רק #2"
|
||||
optionHaveSetupUri: כן, יש לי
|
||||
optionKeepPreviousBehaviour: שמור על התנהגות קודמת
|
||||
optionManualSetup: הגדר הכל ידנית
|
||||
optionNoAskAgain: לא, אנא שאל שוב
|
||||
optionNoSetupUri: לא, אין לי
|
||||
optionRemindNextLaunch: הזכר לי בהפעלה הבאה
|
||||
optionSetupViaP2P: השתמש ב-%{short_p2p_sync} להגדרה
|
||||
optionSetupWizard: קח אותי לאשף ההגדרה
|
||||
optionYesFetchAgain: כן, משוך שוב
|
||||
titleCaseSensitivity: תלות רישיות
|
||||
titleRecommendSetupUri: המלצה לשימוש ב-Setup URI
|
||||
titleWelcome: ברוך הבא ל-Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: שכפל
|
||||
Move remotely deleted files to the trash, instead of deleting.: העבר קבצים שנמחקו מרחוק לאשפה, במקום למחוק.
|
||||
@@ -512,12 +484,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: הפעל
|
||||
btnFix: תקן
|
||||
btnGotItAndUpdated: הבנתי ועדכנתי.
|
||||
btnNext: הבא
|
||||
btnStart: התחל
|
||||
btnTest: בדוק
|
||||
btnUse: השתמש
|
||||
buttonFetch: משוך
|
||||
buttonNext: הבא
|
||||
defaultLanguage: ברירת מחדל
|
||||
descConnectSetupURI: זוהי השיטה המומלצת להגדרת Self-hosted LiveSync עם Setup URI.
|
||||
descCopySetupURI: מושלם להגדרת מכשיר חדש!
|
||||
@@ -586,7 +556,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableEncryptionRecommendation: אנו ממליצים להפעיל הצפנה מקצה לקצה ואת ערפול
|
||||
הנתיב. האם אתה בטוח שברצונך להמשיך ללא הצפנה?
|
||||
msgFetchConfigFromRemote: האם ברצונך למשוך את התצורה מהשרת המרוחד?
|
||||
msgGenerateSetupURI: הכל מוכן! האם ברצונך לייצר Setup URI להגדרת מכשירים אחרים?
|
||||
msgIfConfigNotPersistent: אם תצורת השרת אינה קבועה (למשל, פועלת ב-docker), הערכים
|
||||
כאן עשויים להשתנות. לאחר שתצליח להתחבר, אנא עדכן את ההגדרות ב-local.ini של השרת.
|
||||
msgInvalidPassphrase: ביטוי הסיסמה להצפנה שלך עשוי להיות לא תקין. האם אתה בטוח
|
||||
@@ -659,7 +628,6 @@ obsidianLiveSyncSettingTab:
|
||||
|
||||
שמור רק את ההגדרות. **זהירות: עלול לגרום לפגיעה בנתונים**; בנייה מחדש של מסד
|
||||
הנתונים נדרשת בדרך כלל.
|
||||
msgSelectAndApplyPreset: אנא בחר והחל פריט קבוע מראש כלשהו להשלמת האשף.
|
||||
msgSetCorsCredentials: הגדר cors.credentials
|
||||
msgSetCorsOrigins: הגדר cors.origins
|
||||
msgSetMaxDocSize: הגדר couchdb.max_document_size
|
||||
@@ -718,7 +686,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: שרת מרוחד פעיל
|
||||
titleAppearance: מראה
|
||||
titleConflictResolution: פתרון קונפליקטים
|
||||
titleCongratulations: מזל טוב!
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: הפצת מחיקות
|
||||
titleEncryptionNotEnabled: ההצפנה אינה מופעלת
|
||||
|
||||
@@ -356,7 +356,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: ログを表示
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: 後で確認する
|
||||
@@ -416,19 +415,6 @@ moduleMigration:
|
||||
|
||||
___注意: 設定が変更され、データベースが再フェッチされるまで同期できません。___
|
||||
___注意2: チャンクは完全に不変なので、メタデータと差分のみフェッチできます。___
|
||||
msgInitialSetup: |-
|
||||
このデバイスは**まだセットアップされていません**。セットアッププロセスをご案内します。
|
||||
|
||||
すべてのダイアログの内容はクリップボードにコピーできます。後で参照する必要があれば、Obsidianのノートに貼り付けてください。翻訳ツールを使ってお使いの言語に翻訳することもできます。
|
||||
|
||||
まず、**セットアップURI**をお持ちですか?
|
||||
|
||||
注意: それが何か分からない場合は、[documentation](${URI_DOC})を参照してください。
|
||||
msgRecommendSetupUri: |-
|
||||
セットアップURIを生成して使用することを強くお勧めします。
|
||||
これについて知識がない場合は、[documentation](${URI_DOC})を参照してください(重要です)。
|
||||
|
||||
手動でセットアップしますか?
|
||||
msgSinceV02321: |-
|
||||
v0.23.21以降、self-hosted LiveSyncはデフォルトの動作とデータベース構造を変更しました。以下の変更が行われました:
|
||||
|
||||
@@ -449,18 +435,10 @@ moduleMigration:
|
||||
optionEnableBoth: 両方を有効にする
|
||||
optionEnableFilenameCaseInsensitive: "#1のみ有効にする"
|
||||
optionEnableFixedRevisionForChunks: "#2のみ有効にする"
|
||||
optionHaveSetupUri: はい、持っています
|
||||
optionKeepPreviousBehaviour: 以前の動作を維持
|
||||
optionManualSetup: すべて手動でセットアップ
|
||||
optionNoAskAgain: いいえ、後で確認する
|
||||
optionNoSetupUri: いいえ、持っていません
|
||||
optionRemindNextLaunch: 次回起動時にリマインド
|
||||
optionSetupViaP2P: "%{short_p2p_sync}を使ってセットアップ"
|
||||
optionSetupWizard: セットアップウィザードへ
|
||||
optionYesFetchAgain: はい、再フェッチする
|
||||
titleCaseSensitivity: 大文字小文字の区別
|
||||
titleRecommendSetupUri: セットアップURIの使用を推奨
|
||||
titleWelcome: Self-hosted LiveSyncへようこそ
|
||||
moduleObsidianMenu:
|
||||
replicate: レプリケート
|
||||
More actions: その他の操作
|
||||
@@ -486,12 +464,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: 有効化
|
||||
btnFix: 修正
|
||||
btnGotItAndUpdated: 理解しました、更新しました。
|
||||
btnNext: 次へ
|
||||
btnStart: 開始
|
||||
btnTest: テスト
|
||||
btnUse: 使用
|
||||
buttonFetch: フェッチ
|
||||
buttonNext: 次へ
|
||||
defaultLanguage: デフォルト
|
||||
descConnectSetupURI: セットアップURIを使用してSelf-hosted LiveSyncをセットアップする推奨方法です。
|
||||
descCopySetupURI: 新しいデバイスのセットアップにおすすめ!
|
||||
@@ -556,7 +532,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: chttpd.enable_corsを設定
|
||||
msgEnableEncryptionRecommendation: エンドツーエンド暗号化とパス難読化を有効にすることをお勧めします。暗号化なしで続行してもよろしいですか?
|
||||
msgFetchConfigFromRemote: リモートサーバーから設定を取得しますか?
|
||||
msgGenerateSetupURI: 完了!他のデバイスをセットアップするためのセットアップURIを生成しますか?
|
||||
msgIfConfigNotPersistent: "サーバー設定が永続的でない場合(例:
|
||||
Dockerで実行中)、ここの値は変更される可能性があります。接続できるようになったら、サーバーのlocal.iniの設定を更新してください。"
|
||||
msgInvalidPassphrase: 暗号化パスフレーズが無効かもしれません。続行してもよろしいですか?
|
||||
@@ -599,7 +574,6 @@ obsidianLiveSyncSettingTab:
|
||||
リモートデータベースを再構築した場合も含まれます。
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
設定のみを保存します。**注意: データ破損につながる可能性があります**。通常、データベースの再構築が必要です。
|
||||
msgSelectAndApplyPreset: ウィザードを完了するには、プリセット項目を選択して適用してください。
|
||||
msgSetCorsCredentials: cors.credentialsを設定
|
||||
msgSetCorsOrigins: cors.originsを設定
|
||||
msgSetMaxDocSize: couchdb.max_document_sizeを設定
|
||||
@@ -656,7 +630,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: アクティブなリモートサーバー
|
||||
titleAppearance: 外観
|
||||
titleConflictResolution: 競合解決
|
||||
titleCongratulations: おめでとうございます!
|
||||
titleCouchDB: CouchDB サーバー
|
||||
titleDeletionPropagation: 削除の伝播
|
||||
titleEncryptionNotEnabled: 暗号化が有効になっていません
|
||||
@@ -1213,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": "↓: 로컬 덮어쓰기"
|
||||
@@ -571,7 +571,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: 로그 표시
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: 나중에 확인
|
||||
@@ -631,19 +630,6 @@ moduleMigration:
|
||||
|
||||
___참고: 설정이 변경되고 데이터베이스를 다시 불러오기 전까지는 동기화가 불가능합니다.___
|
||||
___참고2: 청크는 변경이 불가능한 구조이므로, 메타데이터와 차이점만 가져올 수 있습니다.___
|
||||
msgInitialSetup: |-
|
||||
이 기기는 **아직 초기 설정이 완료되지 않았습니다**. 지금부터 설정 과정을 안내해 드리겠습니다.
|
||||
|
||||
모든 대화 내용은 클립보드에 복사할 수 있습니다. 나중에 참고하려면 Obsidian 노트에 붙여넣거나 번역 도구를 활용해 번역하셔도 됩니다.
|
||||
|
||||
먼저, **Setup URI**를 가지고 계신가요?
|
||||
|
||||
참고: Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요.
|
||||
msgRecommendSetupUri: |-
|
||||
Setup URI를 생성해 사용하는 것을 강력히 권장합니다.
|
||||
Setup URI가 무엇인지 잘 모르시겠다면 [문서](${URI_DOC})를 참고해 주세요. 중요한 내용이니 꼭 확인하시기 바랍니다.
|
||||
|
||||
직접 수동 설정을 진행하시겠습니까?
|
||||
msgSinceV02321: |-
|
||||
v0.23.21부터 Self-hosted LiveSync의 기본 동작 방식과 데이터베이스 구조가 변경되었습니다. 변경 내용은 다음과 같습니다:
|
||||
|
||||
@@ -664,18 +650,10 @@ moduleMigration:
|
||||
optionEnableBoth: 둘 다 활성화
|
||||
optionEnableFilenameCaseInsensitive: "#1만 활성화"
|
||||
optionEnableFixedRevisionForChunks: "#2만 활성화"
|
||||
optionHaveSetupUri: 예, 있습니다
|
||||
optionKeepPreviousBehaviour: 이전 동작 유지
|
||||
optionManualSetup: 모든 것을 수동으로 설정
|
||||
optionNoAskAgain: 아니요 (나중에 다시 물어보기)
|
||||
optionNoSetupUri: 아니요, 없습니다
|
||||
optionRemindNextLaunch: 다음 시작 시 알림
|
||||
optionSetupViaP2P: "%{short_p2p_sync}를 사용하여 설정"
|
||||
optionSetupWizard: 설정 마법사로 안내
|
||||
optionYesFetchAgain: 예 (다시 가져오기)
|
||||
titleCaseSensitivity: 대소문자 구분
|
||||
titleRecommendSetupUri: Setup URI 사용 권장
|
||||
titleWelcome: Self-hosted LiveSync에 오신 것을 환영합니다
|
||||
moduleObsidianMenu:
|
||||
replicate: 복제
|
||||
More actions: 추가 작업
|
||||
@@ -722,12 +700,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: 활성화
|
||||
btnFix: 수정
|
||||
btnGotItAndUpdated: 알겠습니다. 업데이트했습니다.
|
||||
btnNext: 다음
|
||||
btnStart: 시작
|
||||
btnTest: 테스트
|
||||
btnUse: 사용
|
||||
buttonFetch: 가져오기
|
||||
buttonNext: 다음
|
||||
defaultLanguage: 기본값
|
||||
descConnectSetupURI: 이것은 Setup URI로 Self-hosted LiveSync를 설정하는 권장 방법입니다.
|
||||
descCopySetupURI: 새 기기 설정에 완벽합니다!
|
||||
@@ -793,7 +769,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: chttpd.enable_cors 설정
|
||||
msgEnableEncryptionRecommendation: 종단 간 암호화와 경로 난독화를 활성화하는 것을 권장합니다. 정말로 암호화 없이 계속하시겠습니까?
|
||||
msgFetchConfigFromRemote: 원격 서버에서 구성을 가져오시겠습니까?
|
||||
msgGenerateSetupURI: 모든 작업이 완료되었습니다! 다른 기기를 설정하기 위해 Setup URI를 생성하시겠습니까?
|
||||
msgIfConfigNotPersistent: "서버 설정이 영구적으로 저장되지 않는 환경(예: Docker에서 실행 중)에서는 이곳의 값들이 변경될 수 있습니다. 연결이 가능해지면 서버의 local.ini 파일에서 설정을 수동으로 업데이트해 주세요."
|
||||
msgInvalidPassphrase: 암호화 패스프레이즈가 유효하지 않을 수 있습니다. 정말로 계속하시겠습니까?
|
||||
msgNewVersionNote: 업그레이드 알림으로 여기에 오셨나요? 버전 기록을 검토해 주세요. 만족하신다면 버튼을 클릭하세요. 새로운 업데이트 시 다시 안내됩니다.
|
||||
@@ -835,7 +810,6 @@ obsidianLiveSyncSettingTab:
|
||||
원격 데이터베이스를 이미 재구축한 경우도 여기에 해당합니다.
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
설정만 저장합니다. **주의: 데이터가 손상될 수 있습니다.** 일반적으로는 데이터베이스 재구축이 필요합니다.
|
||||
msgSelectAndApplyPreset: 마법사를 완료하려면 프리셋 항목을 선택하고 적용해 주세요.
|
||||
msgSetCorsCredentials: cors.credentials 설정
|
||||
msgSetCorsOrigins: cors.origins 설정
|
||||
msgSetMaxDocSize: couchdb.max_document_size 설정
|
||||
@@ -892,7 +866,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: 활성 원격 서버
|
||||
titleAppearance: 모양
|
||||
titleConflictResolution: 충돌 해결
|
||||
titleCongratulations: 축하합니다!
|
||||
titleCouchDB: CouchDB
|
||||
titleDeletionPropagation: 삭제 전파
|
||||
titleEncryptionNotEnabled: 암호화가 활성화되지 않음
|
||||
|
||||
@@ -413,7 +413,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: Показать лог
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/README.md#how-to-use
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: Проверить позже
|
||||
@@ -441,8 +440,6 @@ moduleMigration:
|
||||
logSetupCancelled: Настройка отменена, Self-hosted LiveSync ожидает вашей настройки!
|
||||
msgFetchRemoteAgain: Удалённая база данных, похоже, уже была мигрирована.
|
||||
Конфигурация этого устройства несовместима.
|
||||
msgInitialSetup: Ваше устройство ещё не настроено. У вас есть Setup URI?
|
||||
msgRecommendSetupUri: Мы рекомендуем сгенерировать Setup URI.
|
||||
msgSinceV02321: Начиная с v0.23.21, self-hosted LiveSync изменил поведение и
|
||||
структуру базы данных.
|
||||
optionAdjustRemote: Настроить под удалённую
|
||||
@@ -450,18 +447,10 @@ moduleMigration:
|
||||
optionEnableBoth: Включить оба
|
||||
optionEnableFilenameCaseInsensitive: "Включить только #1"
|
||||
optionEnableFixedRevisionForChunks: "Включить только #2"
|
||||
optionHaveSetupUri: Да, есть
|
||||
optionKeepPreviousBehaviour: Сохранить предыдущее поведение
|
||||
optionManualSetup: Настроить всё вручную
|
||||
optionNoAskAgain: Нет, спросить снова
|
||||
optionNoSetupUri: Нет, нет
|
||||
optionRemindNextLaunch: Напомнить при следующем запуске
|
||||
optionSetupViaP2P: Использовать short_p2p_sync для настройки
|
||||
optionSetupWizard: Перейти в мастер настройки
|
||||
optionYesFetchAgain: Да, загрузить снова
|
||||
titleCaseSensitivity: Чувствительность к регистру
|
||||
titleRecommendSetupUri: Рекомендация использовать Setup URI
|
||||
titleWelcome: Добро пожаловать в Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: Реплицировать
|
||||
More actions: Другие действия
|
||||
@@ -492,12 +481,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: Включить
|
||||
btnFix: Исправить
|
||||
btnGotItAndUpdated: Понял и обновил.
|
||||
btnNext: Далее
|
||||
btnStart: Старт
|
||||
btnTest: Тест
|
||||
btnUse: Использовать
|
||||
buttonFetch: Загрузить
|
||||
buttonNext: Далее
|
||||
defaultLanguage: По умолчанию
|
||||
descConnectSetupURI: Это рекомендуемый способ настройки Self-hosted LiveSync с помощью Setup URI.
|
||||
descCopySetupURI: Идеально подходит для настройки нового устройства!
|
||||
@@ -562,7 +549,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableEncryptionRecommendation: Мы рекомендуем включить сквозное шифрование.
|
||||
Вы уверены, что хотите продолжить без шифрования?
|
||||
msgFetchConfigFromRemote: Вы хотите загрузить конфигурацию с удалённого сервера?
|
||||
msgGenerateSetupURI: Всё готово! Вы хотите сгенерировать Setup URI для настройки других устройств?
|
||||
msgIfConfigNotPersistent: Если конфигурация сервера непостоянна, значения здесь могут измениться.
|
||||
msgInvalidPassphrase: Ваша парольная фраза шифрования может быть недействительна.
|
||||
msgNewVersionNote: Вы пришли из-за уведомления об обновлении? Просмотрите историю версий.
|
||||
@@ -572,7 +558,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgObjectStorageWarning: "ПРЕДУПРЕЖДЕНИЕ: Эта функция в разработке."
|
||||
msgOriginCheck: "Проверка origin: org"
|
||||
msgRebuildRequired: Требуется перестроение баз данных для применения изменений.
|
||||
msgSelectAndApplyPreset: Выберите и примените любой пресет для завершения мастера.
|
||||
msgSetCorsCredentials: Установить cors.credentials
|
||||
msgSetCorsOrigins: Установить cors.origins
|
||||
msgSetMaxDocSize: Установить couchdb.max_document_size
|
||||
@@ -629,7 +614,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: Активный удалённый сервер
|
||||
titleAppearance: Внешний вид
|
||||
titleConflictResolution: Разрешение конфликтов
|
||||
titleCongratulations: Поздравляем!
|
||||
titleCouchDB: Сервер CouchDB
|
||||
titleDeletionPropagation: Распространение удалений
|
||||
titleEncryptionNotEnabled: Шифрование не включено
|
||||
@@ -1074,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": "Нет, верните меня назад"
|
||||
|
||||
@@ -745,27 +745,6 @@ moduleMigration:
|
||||
optionNoAskAgain: 不,請稍後再問我
|
||||
optionYesFetchAgain: 是,再次抓取
|
||||
titleCaseSensitivity: 大小寫敏感性
|
||||
msgInitialSetup: |-
|
||||
你的裝置**尚未完成設定**。讓我引導你完成設定流程。
|
||||
|
||||
請記得,每個對話框的內容都可以複製到剪貼簿。如果日後需要參考,可以貼到 Obsidian 的筆記中,也可以用翻譯工具翻成你的語言。
|
||||
|
||||
首先,你有 **Setup URI** 嗎?
|
||||
|
||||
注意:如果你不知道那是什麼,請參閱[說明文件](${URI_DOC})。
|
||||
msgRecommendSetupUri: |-
|
||||
我們強烈建議你產生 Setup URI 並使用它。
|
||||
如果你對此不熟悉,請參閱[說明文件](${URI_DOC})(再次抱歉,但這很重要)。
|
||||
|
||||
你想要如何手動設定?
|
||||
optionHaveSetupUri: 有,我有
|
||||
optionManualSetup: 全部手動設定
|
||||
optionNoSetupUri: 沒有,我沒有
|
||||
optionRemindNextLaunch: 下次啟動時提醒我
|
||||
optionSetupViaP2P: 使用「%{short_p2p_sync}」進行設定
|
||||
optionSetupWizard: 帶我進入設定精靈
|
||||
titleRecommendSetupUri: 建議使用 Setup URI
|
||||
titleWelcome: 歡迎使用 Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: 複寫
|
||||
More actions: 更多操作
|
||||
@@ -786,9 +765,7 @@ obsidianLiveSyncSettingTab:
|
||||
btnTest: 測試
|
||||
buttonFetch: 抓取
|
||||
btnFix: 修正
|
||||
btnNext: 下一步
|
||||
btnUse: 使用
|
||||
buttonNext: 下一步
|
||||
defaultLanguage: 預設語言
|
||||
descConnectSetupURI: 這是使用 Setup URI 設定 Self-hosted LiveSync 的建議方式。
|
||||
descCopySetupURI: 設定新裝置時非常方便!
|
||||
@@ -868,7 +845,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: 設定 chttpd.enable_cors
|
||||
msgEnableEncryptionRecommendation: 我們建議啟用端對端加密與路徑混淆。你確定要在未加密的情況下繼續嗎?
|
||||
msgFetchConfigFromRemote: 要從遠端伺服器抓取設定嗎?
|
||||
msgGenerateSetupURI: 全部完成!要產生 Setup URI 以便設定其他裝置嗎?
|
||||
msgIfConfigNotPersistent: 如果伺服器設定並非持久保存(例如在 Docker 上執行),這裡的值可能會變動。一旦能夠連線,請更新伺服器 local.ini 中的設定。
|
||||
msgInvalidPassphrase: 你的加密密語可能無效。你確定要繼續嗎?
|
||||
msgNotice: "---注意---"
|
||||
@@ -898,7 +874,6 @@ obsidianLiveSyncSettingTab:
|
||||
這也涵蓋你已重建遠端資料庫的情況。
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
僅儲存設定。**注意:這可能導致資料損毀**;通常仍需要重建資料庫。
|
||||
msgSelectAndApplyPreset: 請選擇並套用任一預設項目以完成精靈。
|
||||
msgSetCorsCredentials: 設定 cors.credentials
|
||||
msgSetCorsOrigins: 設定 cors.origins
|
||||
msgSetMaxDocSize: 設定 couchdb.max_document_size
|
||||
@@ -951,7 +926,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleFetchConfigFromRemote: 從遠端伺服器抓取設定
|
||||
titleFetchSettings: 抓取設定
|
||||
titleConflictResolution: 衝突處理
|
||||
titleCongratulations: 恭喜!
|
||||
titleCouchDB: CouchDB 伺服器
|
||||
titleDeletionPropagation: 刪除傳播
|
||||
titleEncryptionNotEnabled: 尚未啟用加密
|
||||
@@ -1693,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": "不,返回上一步"
|
||||
|
||||
@@ -358,7 +358,6 @@ moduleLocalDatabase:
|
||||
moduleLog:
|
||||
showLog: 显示日志
|
||||
moduleMigration:
|
||||
docUri: https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/zh/README_zh.md#%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8
|
||||
fix0256:
|
||||
buttons:
|
||||
checkItLater: 稍后检查
|
||||
@@ -420,19 +419,6 @@ moduleMigration:
|
||||
|
||||
___注意:在更改配置并再次获取数据库之前,我们无法进行同步。___
|
||||
___注意2:chunks 是完全不可变的,我们只能获取元数据和差异
|
||||
msgInitialSetup: |-
|
||||
您的设备**尚未设置**。让我引导您完成设置过程。
|
||||
|
||||
请记住,每个对话框内容都可以复制到剪贴板。如果以后需要参考,可以将其粘贴到 Obsidian 的笔记中。您也可以使用翻译工具将其翻译成您的语言。
|
||||
|
||||
首先,您有**设置 URI** 吗?
|
||||
|
||||
注意:如果您不知道这是什么,请参阅[文档](${URI_DOC})
|
||||
msgRecommendSetupUri: |-
|
||||
我们强烈建议您生成一个设置 URI 并使用它。
|
||||
如果您对此不了解,请参阅[文档](${URI_DOC})(再次抱歉,但这很重要)。
|
||||
|
||||
您想如何手动设置?
|
||||
msgSinceV02321: |-
|
||||
自 v0.23.21 起,Self-hosted LiveSync 更改了默认行为和数据库结构。进行了以下更改:
|
||||
|
||||
@@ -453,18 +439,10 @@ moduleMigration:
|
||||
optionEnableBoth: 启用两者
|
||||
optionEnableFilenameCaseInsensitive: "仅启用 #1"
|
||||
optionEnableFixedRevisionForChunks: "仅启用 #2"
|
||||
optionHaveSetupUri: 是的,我有
|
||||
optionKeepPreviousBehaviour: 保持以前的行为
|
||||
optionManualSetup: 全部手动设置
|
||||
optionNoAskAgain: 不,请稍后再次询问
|
||||
optionNoSetupUri: 不,我没有
|
||||
optionRemindNextLaunch: 下次启动时提醒我
|
||||
optionSetupViaP2P: Use %{short_p2p_sync} to set up
|
||||
optionSetupWizard: 带我进入设置向导
|
||||
optionYesFetchAgain: 是的,再次获取
|
||||
titleCaseSensitivity: 大小写敏感性
|
||||
titleRecommendSetupUri: 推荐使用设置 URI
|
||||
titleWelcome: 欢迎使用 Self-hosted LiveSync
|
||||
moduleObsidianMenu:
|
||||
replicate: 复制
|
||||
More actions: 更多操作
|
||||
@@ -490,12 +468,10 @@ obsidianLiveSyncSettingTab:
|
||||
btnEnable: 启用
|
||||
btnFix: 修复
|
||||
btnGotItAndUpdated: 我明白了并且已更新
|
||||
btnNext: 下一步
|
||||
btnStart: 开始
|
||||
btnTest: 测试
|
||||
btnUse: 使用
|
||||
buttonFetch: 获取
|
||||
buttonNext: 下一步
|
||||
defaultLanguage: 默认语言
|
||||
descConnectSetupURI: 这是使用设置 URI 设置 Self-hosted LiveSync 的推荐方法
|
||||
descCopySetupURI: 非常适合设置新设备!
|
||||
@@ -560,7 +536,6 @@ obsidianLiveSyncSettingTab:
|
||||
msgEnableCorsChttpd: 设置 chttpd.enable_cors
|
||||
msgEnableEncryptionRecommendation: 建议启用端到端加密和路径混淆。你确定要在未加密的情况下继续吗?
|
||||
msgFetchConfigFromRemote: 要从远端服务器获取配置吗?
|
||||
msgGenerateSetupURI: 全部完成!要生成设置 URI 以便配置其他设备吗?
|
||||
msgIfConfigNotPersistent: 如果服务器配置不是持久的(例如,在 docker 上运行),此处的值可能会更改。一旦能够连接,请更新服务器 local.ini 中的设置
|
||||
msgInvalidPassphrase: 你的加密密码短语可能无效。你确定要继续吗?
|
||||
msgNewVersionNote: 因为升级通知来到这里?请查看版本历史。如果您满意,请点击按钮。新的更新将再次提示此信息
|
||||
@@ -602,7 +577,6 @@ obsidianLiveSyncSettingTab:
|
||||
这种情况包括您已经重建了远程数据库的情况。
|
||||
## ${OPTION_ONLY_SETTING}
|
||||
仅存储设置。**注意:这可能导致数据损坏**;通常需要重建数据库
|
||||
msgSelectAndApplyPreset: 请选择并应用任一预设项以完成向导。
|
||||
msgSetCorsCredentials: 设置 cors.credentials
|
||||
msgSetCorsOrigins: 设置 cors.origins
|
||||
msgSetMaxDocSize: 设置 couchdb.max_document_size
|
||||
@@ -659,7 +633,6 @@ obsidianLiveSyncSettingTab:
|
||||
titleActiveRemoteServer: 活动远程服务器
|
||||
titleAppearance: 外观
|
||||
titleConflictResolution: 冲突处理
|
||||
titleCongratulations: 恭喜!
|
||||
titleCouchDB: CouchDB 服务器
|
||||
titleDeletionPropagation: 删除传播
|
||||
titleEncryptionNotEnabled: 尚未启用加密
|
||||
@@ -1595,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,18 @@
|
||||
/**
|
||||
* Run a finite operation with a flow-owned remote resource and release it
|
||||
* after either success or failure.
|
||||
*
|
||||
* Resource implementations make `dispose()` idempotent. This helper makes the
|
||||
* caller's ownership boundary explicit and prevents finite flows from leaking
|
||||
* a provider-owned resource when their operation rejects.
|
||||
*/
|
||||
export async function withOwnedRemoteResource<TResource extends { dispose(): Promise<void> }, TResult>(
|
||||
resource: TResource,
|
||||
operation: (ownedResource: TResource) => Promise<TResult>
|
||||
): Promise<TResult> {
|
||||
try {
|
||||
return await operation(resource);
|
||||
} finally {
|
||||
await resource.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withOwnedRemoteResource } from "./ownedRemoteResource";
|
||||
|
||||
describe("flow-owned remote resources", () => {
|
||||
it("disposes a resource after a successful finite operation", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const resource = { dispose };
|
||||
|
||||
await expect(
|
||||
withOwnedRemoteResource(resource, async (owned) => (owned === resource ? "done" : "wrong"))
|
||||
).resolves.toBe("done");
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disposes a resource when the finite operation rejects", async () => {
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const error = new Error("resource operation failed");
|
||||
|
||||
await expect(
|
||||
withOwnedRemoteResource({ dispose }, async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
|
||||
|
||||
/**
|
||||
* Compare the effective endpoint rather than inconsequential URI spelling.
|
||||
* Fragments are not sent, query order is immaterial, and redundant trailing
|
||||
* slashes do not bind a different adapter. Invalid input is retained verbatim
|
||||
* and tagged so comparison remains deterministic and fails closed.
|
||||
*/
|
||||
function projectEndpoint(value: string): EndpointProjection {
|
||||
try {
|
||||
const endpoint = new URL(value);
|
||||
endpoint.hash = "";
|
||||
endpoint.searchParams.sort();
|
||||
while (endpoint.pathname.length > 1 && endpoint.pathname.endsWith("/")) {
|
||||
endpoint.pathname = endpoint.pathname.slice(0, -1);
|
||||
}
|
||||
return ["url", endpoint.toString()];
|
||||
} catch {
|
||||
return ["invalid-url", value];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the effective custom-header parser: trim each first name/value pair,
|
||||
* ignore incomplete lines, and let the last duplicate name win. Sorting the
|
||||
* resulting entries prevents line order alone from replacing a Replicator.
|
||||
*/
|
||||
function projectHeaders(value: string): readonly (readonly [name: string, value: string])[] {
|
||||
const headers = new Map<string, string>();
|
||||
for (const line of value.split("\n")) {
|
||||
const [name, headerValue] = line.split(":", 2).map((part) => part.trim());
|
||||
if (name && headerValue) {
|
||||
headers.set(name, headerValue);
|
||||
}
|
||||
}
|
||||
return [...headers.entries()].sort(([leftName, leftValue], [rightName, rightValue]) => {
|
||||
const nameOrder = leftName.localeCompare(rightName);
|
||||
return nameOrder || leftValue.localeCompare(rightValue);
|
||||
});
|
||||
}
|
||||
|
||||
function projectRemoteSecurity(settings: RemoteDBSettings) {
|
||||
return settings.encrypt
|
||||
? ([
|
||||
"encrypted",
|
||||
settings.passphrase,
|
||||
settings.useDynamicIterationCount,
|
||||
settings.E2EEAlgorithm,
|
||||
settings.permitEmptyPassphrase,
|
||||
] as const)
|
||||
: (["plain"] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the effective CouchDB connection settings to a private comparison identity.
|
||||
* The returned value can contain credentials and must not be logged, persisted, or displayed.
|
||||
*/
|
||||
export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSettings): string {
|
||||
const authentication = settings.useJWT
|
||||
? ([
|
||||
"jwt",
|
||||
settings.jwtAlgorithm,
|
||||
settings.jwtKey,
|
||||
settings.jwtKid,
|
||||
settings.jwtSub,
|
||||
settings.jwtExpDuration,
|
||||
] as const)
|
||||
: (["basic", settings.couchDB_USER, settings.couchDB_PASSWORD] as const);
|
||||
return JSON.stringify([
|
||||
"couchdb",
|
||||
projectEndpoint(settings.couchDB_URI),
|
||||
settings.couchDB_DBNAME,
|
||||
authentication,
|
||||
projectHeaders(settings.couchDB_CustomHeaders),
|
||||
settings.useRequestAPI,
|
||||
settings.disableRequestURI,
|
||||
projectRemoteSecurity(settings),
|
||||
settings.enableCompression,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the effective Object Storage connection settings to a private comparison identity.
|
||||
* The returned value can contain credentials and must not be logged, persisted, or displayed.
|
||||
*/
|
||||
export function getObjectStorageReplicatorConfigurationIdentity(settings: RemoteDBSettings): string {
|
||||
return JSON.stringify([
|
||||
"s3",
|
||||
projectEndpoint(settings.endpoint),
|
||||
settings.bucket,
|
||||
settings.bucketPrefix,
|
||||
settings.region,
|
||||
settings.accessKey,
|
||||
settings.secretKey,
|
||||
settings.forcePathStyle,
|
||||
settings.useCustomRequestHandler,
|
||||
projectHeaders(settings.bucketCustomHeaders),
|
||||
projectRemoteSecurity(settings),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
|
||||
describe("active Replicator configuration identity", () => {
|
||||
function configuredSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(createNewVaultSettings(), {
|
||||
activeConfigurationId: "profile-a",
|
||||
couchDB_URI: "https://couch.example.test/base",
|
||||
couchDB_USER: "alice",
|
||||
couchDB_PASSWORD: "secret-a",
|
||||
couchDB_DBNAME: "vault",
|
||||
couchDB_CustomHeaders: "X-Second: two\nX-First: one",
|
||||
endpoint: "https://objects.example.test/base",
|
||||
accessKey: "alice",
|
||||
secretKey: "secret-a",
|
||||
bucket: "vault",
|
||||
bucketPrefix: "notes/",
|
||||
region: "auto",
|
||||
bucketCustomHeaders: "X-Second: two\nX-First: one",
|
||||
encrypt: true,
|
||||
passphrase: "encryption-a",
|
||||
useDynamicIterationCount: false,
|
||||
permitEmptyPassphrase: false,
|
||||
enableCompression: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
["couchDB_URI", "https://other.example.test/base"],
|
||||
["couchDB_DBNAME", "other-vault"],
|
||||
["couchDB_USER", "bob"],
|
||||
["couchDB_PASSWORD", "secret-b"],
|
||||
["couchDB_CustomHeaders", "X-First: changed"],
|
||||
["useRequestAPI", true],
|
||||
["disableRequestURI", true],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
["useDynamicIterationCount", true],
|
||||
["E2EEAlgorithm", ""],
|
||||
["permitEmptyPassphrase", true],
|
||||
["enableCompression", true],
|
||||
] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)(
|
||||
"detects a CouchDB %s change",
|
||||
(key, value) => {
|
||||
const settings = configuredSettings();
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("ignores persisted central profile identity when the effective connection settings match", () => {
|
||||
const settings = configuredSettings({ activeConfigurationId: "profile-a" });
|
||||
const otherProfile = { ...settings, activeConfigurationId: "profile-b" };
|
||||
|
||||
expect(getCouchDBReplicatorConfigurationIdentity(otherProfile)).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(otherProfile)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
});
|
||||
|
||||
it("projects only the active CouchDB authentication mode", () => {
|
||||
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(basic)
|
||||
);
|
||||
|
||||
const jwt = configuredSettings({
|
||||
useJWT: true,
|
||||
jwtAlgorithm: "HS256",
|
||||
jwtKey: "jwt-a",
|
||||
jwtKid: "kid-a",
|
||||
jwtSub: "subject-a",
|
||||
jwtExpDuration: 5,
|
||||
});
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, couchDB_PASSWORD: "inactive" })).toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(jwt)
|
||||
);
|
||||
expect(getCouchDBReplicatorConfigurationIdentity({ ...jwt, jwtKey: "jwt-b" })).not.toBe(
|
||||
getCouchDBReplicatorConfigurationIdentity(jwt)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["endpoint", "https://other.example.test/base"],
|
||||
["bucket", "other-vault"],
|
||||
["bucketPrefix", "archive/"],
|
||||
["region", "eu-west-1"],
|
||||
["accessKey", "bob"],
|
||||
["secretKey", "secret-b"],
|
||||
["forcePathStyle", false],
|
||||
["useCustomRequestHandler", true],
|
||||
["bucketCustomHeaders", "X-First: changed"],
|
||||
["encrypt", false],
|
||||
["passphrase", "encryption-b"],
|
||||
["useDynamicIterationCount", true],
|
||||
["E2EEAlgorithm", ""],
|
||||
["permitEmptyPassphrase", true],
|
||||
] satisfies Array<[keyof ObsidianLiveSyncSettings, ObsidianLiveSyncSettings[keyof ObsidianLiveSyncSettings]]>)(
|
||||
"detects an Object Storage %s change",
|
||||
(key, value) => {
|
||||
const settings = configuredSettings();
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity({ ...settings, [key]: value })).not.toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("normalises endpoint and header representation without using the setup URI grammar", () => {
|
||||
const settings = configuredSettings();
|
||||
const couchIdentity = getCouchDBReplicatorConfigurationIdentity(settings);
|
||||
const objectStorageIdentity = getObjectStorageReplicatorConfigurationIdentity(settings);
|
||||
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
couchDB_URI: "https://couch.example.test:443/base/",
|
||||
couchDB_CustomHeaders: "X-First: one\nX-Second: two",
|
||||
})
|
||||
).toBe(couchIdentity);
|
||||
expect(
|
||||
getObjectStorageReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
endpoint: "https://objects.example.test:443/base/",
|
||||
bucketCustomHeaders: "X-First: one\nX-Second: two",
|
||||
})
|
||||
).toBe(objectStorageIdentity);
|
||||
});
|
||||
|
||||
it("ignores inactive remote-security credentials", () => {
|
||||
const settings = configuredSettings({ encrypt: false, passphrase: "inactive-a" });
|
||||
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
passphrase: "inactive-b",
|
||||
useDynamicIterationCount: !settings.useDynamicIterationCount,
|
||||
E2EEAlgorithm: "",
|
||||
permitEmptyPassphrase: !settings.permitEmptyPassphrase,
|
||||
})
|
||||
).toBe(getCouchDBReplicatorConfigurationIdentity(settings));
|
||||
expect(
|
||||
getObjectStorageReplicatorConfigurationIdentity({
|
||||
...settings,
|
||||
passphrase: "inactive-b",
|
||||
useDynamicIterationCount: !settings.useDynamicIterationCount,
|
||||
E2EEAlgorithm: "",
|
||||
permitEmptyPassphrase: !settings.permitEmptyPassphrase,
|
||||
})
|
||||
).toBe(getObjectStorageReplicatorConfigurationIdentity(settings));
|
||||
});
|
||||
|
||||
it("keeps malformed endpoints deterministic and scoped", () => {
|
||||
const settings = configuredSettings({ couchDB_URI: "not a URL", endpoint: "also not a URL" });
|
||||
|
||||
expect(() => getCouchDBReplicatorConfigurationIdentity(settings)).not.toThrow();
|
||||
expect(() => getObjectStorageReplicatorConfigurationIdentity(settings)).not.toThrow();
|
||||
expect(
|
||||
getCouchDBReplicatorConfigurationIdentity({ ...settings, couchDB_URI: "different invalid URL" })
|
||||
).not.toBe(getCouchDBReplicatorConfigurationIdentity(settings));
|
||||
const unrelatedPluginChange = { ...settings, displayLanguage: "ja" };
|
||||
expect(getObjectStorageReplicatorConfigurationIdentity(unrelatedPluginChange)).toBe(
|
||||
getObjectStorageReplicatorConfigurationIdentity(settings)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CAPABILITY_NOT_APPLICABLE,
|
||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
defineReplicatorProviderDefinitions,
|
||||
supportedOpenReplicationContinuous,
|
||||
replicationBlocked,
|
||||
replicationFailed,
|
||||
supportedStopActiveTransfer,
|
||||
supportedCapability,
|
||||
type ReplicatorProviderDefinitionMap,
|
||||
type ReplicationOutcome,
|
||||
type ReplicatorInstance,
|
||||
type UserInitiatedOneShotRunner,
|
||||
type UnattendedOneShotRunner,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import {
|
||||
getCouchDBReplicatorConfigurationIdentity,
|
||||
getObjectStorageReplicatorConfigurationIdentity,
|
||||
} from "./replicatorConfigurationIdentity";
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
import {
|
||||
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
} from "./centralRemoteAdministration";
|
||||
|
||||
/** Host environment sufficient to construct every current central provider. */
|
||||
export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
/** Minimal operation required by both central one-shot adapters. */
|
||||
interface OneShotOutcomeReplicator extends ReplicatorInstance {
|
||||
openOneShotReplicationWithOutcome(setting: RemoteDBSettings, showResult: boolean): Promise<ReplicationOutcome>;
|
||||
}
|
||||
|
||||
/** Narrow structurally so the shared adapter does not depend on either concrete provider class. */
|
||||
function isOneShotOutcomeReplicator(instance: ReplicatorInstance): instance is OneShotOutcomeReplicator {
|
||||
return (
|
||||
"openOneShotReplicationWithOutcome" in instance &&
|
||||
typeof instance.openOneShotReplicationWithOutcome === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function runOneShotWithOutcome(
|
||||
instance: ReplicatorInstance,
|
||||
setting: RemoteDBSettings,
|
||||
showResult: boolean
|
||||
): Promise<ReplicationOutcome> {
|
||||
if (!isOneShotOutcomeReplicator(instance)) {
|
||||
return replicationFailed(new Error("The configured provider does not implement one-shot replication."));
|
||||
}
|
||||
return await instance.openOneShotReplicationWithOutcome(setting, showResult);
|
||||
}
|
||||
|
||||
// Manual and unattended wrappers share the provider transfer operation, but
|
||||
// keep interaction authority and result presentation explicit at this boundary.
|
||||
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
||||
);
|
||||
};
|
||||
|
||||
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||
return await runOneShotWithOutcome(
|
||||
instance,
|
||||
setting,
|
||||
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
|
||||
);
|
||||
};
|
||||
|
||||
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||
return await runOneShotWithOutcome(instance, setting, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the complete, deliberately concrete central-provider matrix for one
|
||||
* LiveSync host. This closed composition is not a runtime provider registry.
|
||||
*/
|
||||
export function createCentralReplicatorProviderDefinitions(
|
||||
host: CentralReplicatorProviderHost
|
||||
): ReplicatorProviderDefinitionMap {
|
||||
return defineReplicatorProviderDefinitions([REMOTE_COUCHDB, REMOTE_MINIO] as const, {
|
||||
[REMOTE_COUCHDB]: {
|
||||
kind: REMOTE_COUCHDB,
|
||||
diagnosticName: "CouchDB",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_COUCHDB &&
|
||||
!!settings.couchDB_URI?.trim() &&
|
||||
!!settings.couchDB_DBNAME?.trim(),
|
||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createCouchDBPreferredTweakProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability(
|
||||
createCouchDBSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
|
||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||
),
|
||||
},
|
||||
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
||||
continuous: supportedOpenReplicationContinuous(),
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
[REMOTE_MINIO]: {
|
||||
kind: REMOTE_MINIO,
|
||||
diagnosticName: "Object Storage",
|
||||
readiness: CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||
isConfigured: (settings) =>
|
||||
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||
remoteResources: {
|
||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||
createObjectStorageConnectionProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
|
||||
createObjectStoragePreferredTweakProbeFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SECURITY_SEED]: supportedCapability(
|
||||
createObjectStorageSecuritySeedResourceFactory(host)
|
||||
),
|
||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||
},
|
||||
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
CAPABILITY_SUPPORT_KINDS,
|
||||
NO_INTERACTION,
|
||||
REPLICATION_COMPLETED,
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const constructorMocks = vi.hoisted(() => ({
|
||||
couchDB: vi.fn(),
|
||||
couchDBOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||
objectStorage: vi.fn(),
|
||||
objectStorageOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {
|
||||
constructor(host: unknown) {
|
||||
constructorMocks.couchDB(host);
|
||||
}
|
||||
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||
return constructorMocks.couchDBOneShot(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
constructor(host: unknown) {
|
||||
constructorMocks.objectStorage(host);
|
||||
}
|
||||
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||
return constructorMocks.objectStorageOneShot(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders";
|
||||
|
||||
describe("central Replicator provider definitions", () => {
|
||||
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||
expect
|
||||
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||
});
|
||||
|
||||
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
||||
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
||||
const definitions = createCentralReplicatorProviderDefinitions(host);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
|
||||
expect([...definitions.keys()]).toEqual([REMOTE_COUCHDB, REMOTE_MINIO]);
|
||||
expect("sameKindReconciliation" in couchDB).toBe(false);
|
||||
expect("sameKindReconciliation" in objectStorage).toBe(false);
|
||||
|
||||
expect(
|
||||
couchDB.isConfigured(
|
||||
Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://couch.example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
objectStorage.isConfigured(
|
||||
Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_MINIO,
|
||||
endpoint: "https://objects.example.test",
|
||||
bucket: "vault",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
await couchDB.create(createNewVaultSettings());
|
||||
await objectStorage.create(createNewVaultSettings());
|
||||
expect(constructorMocks.couchDB).toHaveBeenCalledWith(host);
|
||||
expect(constructorMocks.objectStorage).toHaveBeenCalledWith(host);
|
||||
});
|
||||
|
||||
it("rejects incomplete and wrong-kind settings before construction", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
|
||||
expect(couchDB.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_MINIO }))).toBe(false);
|
||||
expect(
|
||||
objectStorage.isConfigured(Object.assign(createNewVaultSettings(), { remoteType: REMOTE_COUCHDB }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("declares the retained owned resources and cohesive optional administration", () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources;
|
||||
const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources;
|
||||
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||
|
||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.SUPPORTED
|
||||
);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||
);
|
||||
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
expect("fullTransfers" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||
});
|
||||
|
||||
it("dispatches central finite work through provider-local attempt results", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const couchInstance = await couchDB.create(setting);
|
||||
const objectInstance = await objectStorage.create(setting);
|
||||
if (!couchInstance || !objectInstance) throw new Error("Provider construction failed");
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Object Storage OneShot is unavailable");
|
||||
}
|
||||
|
||||
await expect(
|
||||
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
await expect(
|
||||
objectStorage.unattendedOneShot.run(objectInstance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
})
|
||||
).resolves.toBe(REPLICATION_COMPLETED);
|
||||
|
||||
expect(constructorMocks.couchDBOneShot).toHaveBeenCalledWith(setting, true);
|
||||
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["CouchDB", REMOTE_COUCHDB],
|
||||
["Object Storage", REMOTE_MINIO],
|
||||
] as const)("maps %s progress presentation independently of recovery authority", async (label, remoteType) => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const definition = definitions.get(remoteType)!;
|
||||
if (definition.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error(`${label} OneShot is unavailable`);
|
||||
}
|
||||
const setting = Object.assign(createNewVaultSettings(), { remoteType });
|
||||
const openOneShotReplicationWithOutcome = vi.fn(async () => REPLICATION_COMPLETED);
|
||||
const instance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome,
|
||||
};
|
||||
|
||||
await definition.userInitiatedOneShot.run(instance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
await definition.userInitiatedOneShot.run(instance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(1, setting, false);
|
||||
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
|
||||
});
|
||||
|
||||
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const createStructuralOneShotReplicator = () => ({
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||
});
|
||||
const couchInstance = createStructuralOneShotReplicator();
|
||||
const objectStorageInstance = createStructuralOneShotReplicator();
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("Object Storage OneShot is unavailable");
|
||||
}
|
||||
|
||||
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
|
||||
trigger: "resume",
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
|
||||
expect.soft(couchOutcome).toBe(REPLICATION_COMPLETED);
|
||||
expect.soft(objectStorageOutcome).toBe(REPLICATION_COMPLETED);
|
||||
expect(couchInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, true);
|
||||
expect(objectStorageInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, false);
|
||||
});
|
||||
|
||||
it("rejects a one-shot adapter whose Replicator does not declare the required operation", async () => {
|
||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||
const setting = createNewVaultSettings();
|
||||
const incompleteInstance = {
|
||||
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||
openReplication: vi.fn(async () => true),
|
||||
terminateSync: vi.fn(),
|
||||
closeReplication: vi.fn(),
|
||||
};
|
||||
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||
throw new Error("CouchDB OneShot is unavailable");
|
||||
}
|
||||
|
||||
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
|
||||
trigger: "manual",
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(incompleteInstance.openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
logger: vi.fn(),
|
||||
couchDB: [] as Array<{
|
||||
host: unknown;
|
||||
isMobile: ReturnType<typeof vi.fn>;
|
||||
connectRemoteCouchDBWithSetting: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
objectStorage: [] as Array<{
|
||||
host: unknown;
|
||||
tryConnectRemote: ReturnType<typeof vi.fn>;
|
||||
getRemoteStatus: ReturnType<typeof vi.fn>;
|
||||
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
|
||||
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
|
||||
closeReplication: ReturnType<typeof vi.fn>;
|
||||
}>,
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/logger")>();
|
||||
return { ...actual, Logger: mocks.logger };
|
||||
});
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
|
||||
checkSyncInfo: mocks.checkSyncInfo,
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||
LiveSyncCouchDBReplicator: class {
|
||||
host: unknown;
|
||||
isMobile = vi.fn(() => false);
|
||||
connectRemoteCouchDBWithSetting = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
this.host = host;
|
||||
mocks.couchDB.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||
LiveSyncJournalReplicator: class {
|
||||
host: unknown;
|
||||
tryConnectRemote = vi.fn();
|
||||
getRemoteStatus = vi.fn();
|
||||
getRemotePreferredTweakValues = vi.fn();
|
||||
getReplicationPBKDF2Salt = vi.fn();
|
||||
closeReplication = vi.fn();
|
||||
|
||||
constructor(host: unknown) {
|
||||
this.host = host;
|
||||
mocks.objectStorage.push(this);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createCouchDBSynchronisationInformationResourceFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
} from "./replicatorResources";
|
||||
|
||||
function createSettings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(createNewVaultSettings(), {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "https://couch.example.test",
|
||||
couchDB_DBNAME: "vault",
|
||||
endpoint: "https://objects.example.test",
|
||||
bucket: "vault",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("replicator probe factories", () => {
|
||||
beforeEach(() => {
|
||||
mocks.couchDB.length = 0;
|
||||
mocks.objectStorage.length = 0;
|
||||
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
|
||||
mocks.logger.mockClear();
|
||||
});
|
||||
|
||||
it("binds a CouchDB connection probe to a shallow settings snapshot and closes its owned connection", async () => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
const probe = await createCouchDBConnectionProbeFactory(host as never)(source);
|
||||
const replicator = mocks.couchDB[0];
|
||||
const close = vi.fn(async () => undefined);
|
||||
const databaseClose = vi.fn(async () => undefined);
|
||||
replicator.isMobile.mockReturnValue(true);
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
|
||||
db: { close: databaseClose },
|
||||
info: {},
|
||||
close,
|
||||
});
|
||||
|
||||
source.couchDB_URI = "https://changed.example.test";
|
||||
expect(await probe.check({ createIfMissing: false, showResult: true })).toEqual({ ok: true });
|
||||
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, true, false, false);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting.mock.calls[0][0]).not.toBe(source);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(databaseClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
|
||||
const source = createSettings();
|
||||
const snapshot = { ...source };
|
||||
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue("connection failed");
|
||||
|
||||
expect(await probe.check()).toEqual({ ok: false, reason: "connection failed" });
|
||||
|
||||
const status = { estimatedSize: 12 };
|
||||
replicator.getRemoteStatus.mockResolvedValue(status);
|
||||
source.couchDB_DBNAME = "changed-vault";
|
||||
expect(await probe.getStatus()).toBe(status);
|
||||
expect(replicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it("emits a result Notice only for an explicitly visible successful CouchDB probe", async () => {
|
||||
const probe = await createCouchDBConnectionProbeFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
|
||||
info: { db_name: "vault" },
|
||||
close: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(mocks.logger).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger).toHaveBeenCalledWith("Connected to vault successfully", LOG_LEVEL_NOTICE);
|
||||
|
||||
mocks.logger.mockClear();
|
||||
await expect(probe.check()).resolves.toEqual({ ok: true });
|
||||
expect(mocks.logger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits a result Notice only for an explicitly visible CouchDB connection failure", async () => {
|
||||
const reason = "connection failed";
|
||||
const translatedFailure = "translated CouchDB connection failure";
|
||||
const translate = vi.fn(() => translatedFailure);
|
||||
const settings = createSettings();
|
||||
const probe = await createCouchDBConnectionProbeFactory({ services: { context: { translate } } } as never)(
|
||||
settings
|
||||
);
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
|
||||
|
||||
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: false, reason });
|
||||
|
||||
expect(mocks.logger).toHaveBeenCalledTimes(1);
|
||||
expect(translate).toHaveBeenCalledWith("liveSyncReplicator.couldNotConnectTo", {
|
||||
uri: settings.couchDB_URI,
|
||||
name: settings.couchDB_DBNAME,
|
||||
db: reason,
|
||||
});
|
||||
expect(mocks.logger).toHaveBeenCalledWith(translatedFailure, LOG_LEVEL_NOTICE);
|
||||
|
||||
mocks.logger.mockClear();
|
||||
translate.mockClear();
|
||||
await expect(probe.check()).resolves.toEqual({ ok: false, reason });
|
||||
expect(mocks.logger).not.toHaveBeenCalled();
|
||||
expect(translate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates an unpublished Object Storage replicator for each probe and normalises connection results", async () => {
|
||||
const host = { name: "host" };
|
||||
const source = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const snapshot = { ...source };
|
||||
const factory = createObjectStorageConnectionProbeFactory(host as never);
|
||||
const firstProbe = await factory(source);
|
||||
const secondProbe = await factory(source);
|
||||
expect(mocks.objectStorage).toHaveLength(2);
|
||||
|
||||
const firstReplicator = mocks.objectStorage[0];
|
||||
firstReplicator.tryConnectRemote.mockResolvedValue(true);
|
||||
source.endpoint = "https://changed.example.test";
|
||||
expect(await firstProbe.check()).toEqual({ ok: true });
|
||||
expect(firstReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, false);
|
||||
|
||||
const secondReplicator = mocks.objectStorage[1];
|
||||
secondReplicator.tryConnectRemote.mockResolvedValue(false);
|
||||
expect(await secondProbe.check({ showResult: true })).toEqual({ ok: false });
|
||||
expect(secondReplicator.tryConnectRemote).toHaveBeenCalledWith(snapshot, true);
|
||||
|
||||
const error = new Error("storage offline");
|
||||
secondReplicator.tryConnectRemote.mockRejectedValue(error);
|
||||
expect(await secondProbe.check()).toEqual({ ok: false, reason: error });
|
||||
});
|
||||
|
||||
it("delegates Object Storage status and preferred-tweak reads to the trial snapshot", async () => {
|
||||
const source = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const snapshot = { ...source };
|
||||
const connectionProbe = await createObjectStorageConnectionProbeFactory({} as never)(source);
|
||||
const preferredProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(source);
|
||||
const connectionReplicator = mocks.objectStorage[0];
|
||||
const preferredReplicator = mocks.objectStorage[1];
|
||||
const status = { estimatedSize: 42 };
|
||||
const preferred = { status: "unsupported" } as const;
|
||||
connectionReplicator.getRemoteStatus.mockResolvedValue(status);
|
||||
preferredReplicator.getRemotePreferredTweakValues.mockResolvedValue(preferred);
|
||||
|
||||
source.bucket = "changed-vault";
|
||||
expect(await connectionProbe.getStatus()).toBe(status);
|
||||
expect(await preferredProbe.read()).toBe(preferred);
|
||||
expect(connectionReplicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
|
||||
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
|
||||
});
|
||||
|
||||
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
|
||||
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
|
||||
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
|
||||
createSettings({ remoteType: REMOTE_MINIO })
|
||||
);
|
||||
const couchReplicator = mocks.couchDB[0];
|
||||
const objectReplicator = mocks.objectStorage[0];
|
||||
|
||||
const couchDisposal = couchProbe.dispose();
|
||||
expect(couchProbe.dispose()).toBe(couchDisposal);
|
||||
const objectDisposal = objectProbe.dispose();
|
||||
expect(objectProbe.dispose()).toBe(objectDisposal);
|
||||
await Promise.all([couchDisposal, objectDisposal]);
|
||||
expect(couchReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shares a rejected disposal promise and never retries closeReplication", async () => {
|
||||
const probe = await createObjectStorageConnectionProbeFactory({} as never)(
|
||||
createSettings({ remoteType: REMOTE_MINIO })
|
||||
);
|
||||
const replicator = mocks.objectStorage[0];
|
||||
const failure = new Error("close failed");
|
||||
replicator.closeReplication.mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
|
||||
const disposal = probe.dispose();
|
||||
expect(probe.dispose()).toBe(disposal);
|
||||
await expect(disposal).rejects.toBe(failure);
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the Security Seed from a settings snapshot and disposes its private Replicator", async () => {
|
||||
const couchSettings = createSettings();
|
||||
const couchSnapshot = { ...couchSettings };
|
||||
const objectSettings = createSettings({ remoteType: REMOTE_MINIO });
|
||||
const objectSnapshot = { ...objectSettings };
|
||||
const couchResource = await createCouchDBSecuritySeedResourceFactory({} as never)(couchSettings);
|
||||
const objectResource = await createObjectStorageSecuritySeedResourceFactory({} as never)(objectSettings);
|
||||
const couchReplicator = mocks.couchDB[0];
|
||||
const objectReplicator = mocks.objectStorage[0];
|
||||
const couchSeed = new Uint8Array([1]);
|
||||
const objectSeed = new Uint8Array([2]);
|
||||
couchReplicator.getReplicationPBKDF2Salt.mockResolvedValue(couchSeed);
|
||||
objectReplicator.getReplicationPBKDF2Salt.mockResolvedValue(objectSeed);
|
||||
|
||||
couchSettings.couchDB_URI = "https://changed.example.test";
|
||||
objectSettings.endpoint = "https://changed.example.test";
|
||||
await expect(couchResource.read()).resolves.toBe(couchSeed);
|
||||
await expect(objectResource.read()).resolves.toBe(objectSeed);
|
||||
expect(couchReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(couchSnapshot, true);
|
||||
expect(objectReplicator.getReplicationPBKDF2Salt).toHaveBeenCalledWith(objectSnapshot, true);
|
||||
|
||||
await Promise.all([couchResource.dispose(), objectResource.dispose()]);
|
||||
expect(couchReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
expect(objectReplicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("checks synchronisation information through an owned connection and disposes the private Replicator", async () => {
|
||||
const settings = createSettings();
|
||||
const snapshot = { ...settings };
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(settings);
|
||||
const replicator = mocks.couchDB[0];
|
||||
const database = { close: vi.fn() };
|
||||
const close = vi.fn(async () => undefined);
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close });
|
||||
|
||||
settings.couchDB_DBNAME = "changed-vault";
|
||||
await expect(resource.check()).resolves.toBe(true);
|
||||
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(snapshot, false, true);
|
||||
expect(mocks.checkSyncInfo).toHaveBeenCalledWith(database);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(database.close).not.toHaveBeenCalled();
|
||||
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves a CouchDB connection or setup failure for the settings flow to report", async () => {
|
||||
const reason = "connection failed";
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
|
||||
|
||||
await expect(resource.check()).rejects.toMatchObject({ message: reason });
|
||||
});
|
||||
|
||||
it("closes the owned connection when synchronisation-information verification rejects", async () => {
|
||||
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
|
||||
const replicator = mocks.couchDB[0];
|
||||
const database = { close: vi.fn() };
|
||||
const close = vi.fn(async () => undefined);
|
||||
const failure = new Error("verification failed");
|
||||
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: database, close });
|
||||
mocks.checkSyncInfo.mockRejectedValue(failure);
|
||||
|
||||
await expect(resource.check()).rejects.toBe(failure);
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(database.close).not.toHaveBeenCalled();
|
||||
|
||||
await resource.dispose();
|
||||
expect(replicator.closeReplication).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
ConnectionProbeFactory,
|
||||
RemoteConnectionProbe,
|
||||
RemoteConnectionProbeOptions,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
LiveSyncCouchDBReplicator,
|
||||
type LiveSyncCouchDBReplicatorEnv,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
|
||||
|
||||
/** Host environment sufficient to construct either central connection probe. */
|
||||
export type ConnectionResourceHost = LiveSyncCouchDBReplicatorEnv;
|
||||
|
||||
function createCouchDBConnectionProbe(
|
||||
replicator: LiveSyncCouchDBReplicator,
|
||||
snapshot: RemoteDBSettings,
|
||||
host: ConnectionResourceHost
|
||||
): RemoteConnectionProbe {
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
return {
|
||||
check: async (options: RemoteConnectionProbeOptions = {}) => {
|
||||
const connection = await replicator.connectRemoteCouchDBWithSetting(
|
||||
snapshot,
|
||||
replicator.isMobile(),
|
||||
options.createIfMissing ?? true,
|
||||
false
|
||||
);
|
||||
if (typeof connection === "string") {
|
||||
if (options.showResult) {
|
||||
Logger(
|
||||
host.services.context.translate("liveSyncReplicator.couldNotConnectTo", {
|
||||
uri: snapshot.couchDB_URI,
|
||||
name: snapshot.couchDB_DBNAME,
|
||||
db: connection,
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
return { ok: false, reason: connection };
|
||||
}
|
||||
try {
|
||||
if (options.showResult) {
|
||||
Logger(`Connected to ${connection.info.db_name} successfully`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
},
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function createObjectStorageConnectionProbe(
|
||||
replicator: LiveSyncJournalReplicator,
|
||||
snapshot: RemoteDBSettings
|
||||
): RemoteConnectionProbe {
|
||||
const dispose = createReplicatorDisposer(replicator);
|
||||
return {
|
||||
check: async (options: RemoteConnectionProbeOptions = {}) => {
|
||||
try {
|
||||
const connected = await replicator.tryConnectRemote(snapshot, options.showResult ?? false);
|
||||
return connected ? { ok: true } : { ok: false };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error };
|
||||
}
|
||||
},
|
||||
getStatus: () => replicator.getRemoteStatus(snapshot),
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unpublished CouchDB connection probe for one host.
|
||||
*
|
||||
* The probe owns both its concrete Replicator and each connection it opens. It
|
||||
* never publishes that Replicator as the active provider instance. A caller
|
||||
* may request the established result Notice explicitly; ordinary probes remain
|
||||
* silent.
|
||||
*/
|
||||
export function createCouchDBConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot, host));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unpublished Object Storage connection probe for one host.
|
||||
*
|
||||
* The probe owns its concrete Replicator and never publishes or replaces the
|
||||
* active provider instance.
|
||||
*/
|
||||
export function createObjectStorageConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
|
||||
return (setting) => {
|
||||
const snapshot = snapshotRemoteSettings(setting);
|
||||
return Promise.resolve(createObjectStorageConnectionProbe(new LiveSyncJournalReplicator(host), snapshot));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
createCouchDBConnectionProbeFactory,
|
||||
createObjectStorageConnectionProbeFactory,
|
||||
type ConnectionResourceHost,
|
||||
} from "./connection";
|
||||
export {
|
||||
createCouchDBPreferredTweakProbeFactory,
|
||||
createObjectStoragePreferredTweakProbeFactory,
|
||||
type PreferredTweakResourceHost,
|
||||
} from "./preferredTweak";
|
||||
export {
|
||||
createCouchDBSecuritySeedResourceFactory,
|
||||
createObjectStorageSecuritySeedResourceFactory,
|
||||
type SecuritySeedResourceHost,
|
||||
} from "./securitySeed";
|
||||
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user