Compare commits

..
Author SHA1 Message Date
vorotamoroz c3e12cf946 Avoid resolving CLI development peers in Docker runtime 2026-09-04 16:25:36 +00:00
vorotamoroz b90ef3716c Merge pull request #1167 from vrtmrz/fix/issue-1164-partial-scan-readiness
Keep synchronisation ready after partial startup scans
2026-09-05 00:44:54 +09:00
vorotamoroz e1195629b9 Use Commonlib 0.1.22 2026-09-04 15:12:47 +00:00
vorotamoroz 0ecb73924a Document diagnostic and notice ownership 2026-09-04 14:10:40 +00:00
vorotamoroz 188b749326 Warn after partial startup scans 2026-09-04 13:54:36 +00:00
vorotamoroz 6abc5cba64 Keep startup ready after individual file failures 2026-09-04 12:50:46 +00:00
vorotamoroz 045a328697 Merge pull request #1162 from vrtmrz/refactor/conflict-resolution-service-features
Refactor conflict resolution into service features
2026-09-04 18:31:00 +09:00
vorotamoroz b6b9ce3ba1 Document conflict dialogue lifecycle fixes 2026-09-04 08:30:42 +00:00
vorotamoroz f06f33cbf4 Strengthen conflict resolution regression coverage 2026-09-04 08:16:14 +00:00
vorotamoroz 56a1a19d2c Merge latest main into conflict resolution refactor 2026-09-04 05:35:39 +00:00
vorotamoroz 84be444689 Simplify conflict scheduling and lifecycle subscriptions 2026-09-04 05:15:45 +00:00
vorotamoroz 54d276f5e5 Merge pull request #1161 from vrtmrz/refactor/startup-lifecycle-service-features
Refactor startup lifecycle into service features
2026-09-04 11:37:47 +09:00
vorotamoroz f70bdbbbe0 refactor: clarify startup operation defaults 2026-09-04 02:30:40 +00:00
vorotamoroz 22a835519b fix: preserve startup UI behaviour 2026-09-04 01:55:29 +00:00
vorotamoroz ad91776ad9 Test conflict dialogue concurrency and unload lifecycle 2026-09-04 01:47:29 +00:00
vorotamoroz 6ea906b575 Refactor conflict resolution into service features 2026-09-03 12:35:58 +00:00
vorotamoroz 338aecd888 Refactor startup lifecycle into service features 2026-09-03 12:20:05 +00:00
vorotamoroz 3b2d5aa5af Merge pull request #1160 from vrtmrz/docs/current-data-structure-reference
Correct the database structure reference for 1.0
2026-09-03 16:39:46 +09:00
vorotamoroz f055222160 Document current database structure boundary 2026-09-03 07:23:15 +00:00
vorotamoroz 77882c677b Merge pull request #1159 from vrtmrz/docs/replicator-architecture
Document the implemented Replicator architecture
2026-09-03 15:57:31 +09:00
vorotamoroz 2461d37ead Document the implemented Replicator architecture
- mark capability and lifecycle ADRs as accepted
- add lifecycle, fencing, and provider-extension guidance
- split project terminology into a dedicated glossary
2026-09-03 06:42:34 +00:00
vorotamoroz d00c5ecc56 Merge pull request #1158 from vrtmrz/1_0_24
Releasing 1.0.24
2026-09-03 14:47:22 +09:00
vorotamoroz 7b4f7bf514 Prepare release 1.0.24 2026-09-03 04:17:05 +00:00
vorotamoroz ba8f910865 Merge pull request #1157 from vrtmrz/fix/setup-wizard-synchronising-device-copy
Align existing-device setup guidance with synchronising-device terminology
2026-09-03 12:17:26 +09:00
vorotamoroz c58e462057 Align existing-device setup guidance terminology 2026-09-03 03:01:05 +00:00
vorotamoroz a09c59aaba Merge pull request #1118 from nikhilmaddirala/agent/fix-setup-wizard-existing-device-copy
Fix existing-device setup guidance
2026-09-03 00:38:57 +09:00
vorotamoroz cbbae33d67 Merge pull request #1130 from zeedif/feat/setup-wizard-ux
feat(setup-wizard): improve E2EE dialog UX and password field
2026-09-02 23:54:25 +09:00
vorotamoroz be9c328f4a fix(setup-wizard): enlarge mobile password toggle 2026-09-02 14:47:21 +00:00
vorotamoroz a9b146a0ab test(setup-wizard): enforce mobile password touch target 2026-09-02 14:47:21 +00:00
vorotamoroz 5d5e448c6b Merge pull request #1129 from zeedif/fix/es-locale-and-onboarding-ux
fix(i18n): Spanish catalogue placeholders and missing strings
2026-09-02 23:27:39 +09:00
vorotamoroz 725db213c3 Merge pull request #1150 from vrtmrz/fix/1142-cli-systemd-installer
Fix systemd CLI installation and start-up checks
2026-09-02 21:43:49 +09:00
vorotamoroz a2441b9870 Integrate merged E2EE rebuild fix
# Conflicts:
#	updates.md
2026-09-02 12:34:58 +00:00
vorotamoroz 01c38268c7 Merge pull request #1149 from vrtmrz/fix/1146-preserve-e2ee-on-rebuild
Preserve device E2EE settings during remote rebuild
2026-09-02 21:32:54 +09:00
vorotamoroz 64e17ab920 Clarify systemd CLI installation guidance 2026-09-02 12:20:50 +00:00
vorotamoroz d8fccd6e4b Integrate systemd CLI installer fix with current main 2026-09-02 11:43:59 +00:00
vorotamoroz 50ad4c4bdf Integrate E2EE rebuild preservation with current main 2026-09-02 11:30:20 +00:00
vorotamoroz 8cb5d6d87f Adopt Replicator capability and lifecycle orchestration (#1154)
Merge the reviewed Replicator ownership and capability refactor after exact-release validation, stable promotion, and successful BRAT testing.
2026-09-02 16:59:20 +09:00
vorotamoroz 79eb940d80 Release Self-hosted LiveSync 1.0.23
Merge the exact reviewed release commit after published-artefact and BRAT validation.
2026-09-02 16:23:18 +09:00
vorotamoroz 28e76701c4 Add personal note to 1.0.23 release notes 2026-09-02 05:23:43 +00:00
github-actions[bot] c8aa40775b Releasing 1.0.23 2026-09-02 05:10:17 +00:00
vorotamoroz fdf5de4c10 Reconcile failed 1.0.22 release metadata 2026-09-02 04:57:10 +00:00
vorotamoroz 9e9fb5c326 chore: update livesync-commonlib to 0.1.21 2026-09-02 03:37:13 +00:00
vorotamoroz 2b450a300a Update vulnerable development dependencies 2026-09-01 18:25:27 +00:00
vorotamoroz ef3033d8db Restore Sync now progress and recovery policy 2026-09-01 18:00:08 +00:00
vorotamoroz 8e506fdbc7 Retire redundant P2P Review Harness check 2026-09-01 12:08:50 +00:00
vorotamoroz cab6679c2b Route interactive P2P UI through focused views 2026-09-01 11:33:54 +00:00
vorotamoroz f737701695 Classify P2P replacement probe as test support 2026-09-01 11:33:26 +00:00
vorotamoroz 565749ef6c Remove unused WebdriverIO dependency 2026-09-01 11:02:51 +00:00
vorotamoroz 32c7cc8aca Complete WebApp P2P local preparation 2026-09-01 08:41:39 +00:00
vorotamoroz 5c5f454286 Record Commonlib 0.1.20 in capability ADRs 2026-09-01 06:08:31 +00:00
vorotamoroz c4d9f8687e Adopt Commonlib 0.1.20 2026-09-01 05:39:19 +00:00
vorotamoroz bd0581cdc9 Report incomplete Object Storage wipes 2026-09-01 01:23:34 +00:00
vorotamoroz 9592ce8529 Merge remote-tracking branch 'origin/main' into refactor/replicator-capability-contract
# Conflicts:
#	devs.md
2026-08-31 15:13:31 +00:00
vorotamoroz e2d1bb09ae Refresh capability ADRs and Object Storage E2E evidence 2026-08-31 15:07:13 +00:00
vorotamoroz 3f76ad796e Migrate P2P UI and CLI transfers to service views 2026-08-31 15:06:39 +00:00
vorotamoroz 1d2077d3fc Complete central provider, resource, scheduling, and recovery contracts 2026-08-31 15:06:06 +00:00
vorotamoroz 24228bf7cf Update Hidden File Sync settings guidance 2026-08-31 00:35:42 +00:00
vorotamoroz 9a28d46287 Harden real-runtime workflow validation 2026-08-31 00:35:29 +00:00
vorotamoroz 3a23b7a6b0 Test CLI remote administration exit codes 2026-08-30 16:39:35 +00:00
vorotamoroz 23dd4ab87e Defer replication runtime database acquisition 2026-08-30 16:39:15 +00:00
vorotamoroz 6364988453 Record the final Replicator capability contract 2026-08-30 16:16:44 +00:00
vorotamoroz 76318944a4 Route P2P Setup probes through the room owner 2026-08-30 16:16:17 +00:00
vorotamoroz 704e141fd9 Refresh owned Security Seed observations 2026-08-30 16:15:55 +00:00
vorotamoroz 69cd6250d2 Tighten provider adapter boundaries 2026-08-30 12:42:34 +00:00
vorotamoroz 72f033fca4 Compose replication lifecycle without a legacy module 2026-08-30 10:23:04 +00:00
vorotamoroz 415f81d533 Merge pull request #1151 from vrtmrz/docs/service-feature-guidance
Document service feature and legacy Module boundaries
2026-08-30 19:13:56 +09:00
vorotamoroz 91625a1c76 Document service feature and legacy Module boundaries 2026-08-30 10:10:58 +00:00
vorotamoroz a5756503a2 Reconcile the Replicator capability design 2026-08-30 09:45:19 +00:00
vorotamoroz 7300db08d6 Adopt active Replicator ownership contracts 2026-08-30 09:45:00 +00:00
vorotamoroz 4b47ebbd4d fix(cli): install complete systemd runtime 2026-08-30 09:38:34 +00:00
vorotamoroz b28871ab67 Preserve device E2EE settings during remote rebuild 2026-08-30 07:43:20 +00:00
vorotamoroz db70b4c2b6 Migrate LiveSync flows to provider-owned resources 2026-08-28 17:54:04 +00:00
vorotamoroz f7206b1a6e Compose replication scheduling as a service feature 2026-08-28 06:05:29 +00:00
vorotamoroz fc160ee060 Define the replication scheduling service boundary 2026-08-28 04:10:48 +00:00
vorotamoroz 22b0cc133a Clarify service composition boundaries 2026-08-28 03:34:03 +00:00
vorotamoroz 948f961caa Record the implemented P2P ownership boundary 2026-08-28 03:05:09 +00:00
vorotamoroz ad83ae858d Adopt focused P2P service views 2026-08-28 03:04:53 +00:00
vorotamoroz cc000f2cdf Clarify P2P session lifecycle contracts 2026-08-27 17:58:19 +00:00
vorotamoroz d24bda88be Dispose temporary P2P setup probes 2026-08-27 17:57:59 +00:00
vorotamoroz 4dcc783a71 Use stable P2P service views in the CLI 2026-08-27 15:44:04 +00:00
vorotamoroz be03c25904 Remove obsolete P2P composition comment 2026-08-27 13:52:10 +00:00
vorotamoroz 7cf4ec49ed Route transfer stops through provider capabilities 2026-08-27 13:18:46 +00:00
vorotamoroz 8e5b058eef Define P2P session cancellation ownership 2026-08-27 13:18:17 +00:00
vorotamoroz 56444bb98b Route replication through provider capabilities 2026-08-27 11:16:30 +00:00
vorotamoroz 7aa41baf08 Document replicator capability and lifecycle contracts 2026-08-27 09:02:09 +00:00
Zeedif 2c35f45765 fix(setup-wizard): address review — icon, CSS scope, E2E coverage
- Use a plain emoji glyph for the password visibility toggle instead
  of embedded SVG paths, matching the same pragmatic approach already
  used for the browser build's menu icons in this codebase.
- Scope the wider label width to the E2EE dialogue instead of
  changing it for every InputRow in every Svelte dialogue.
- Extend the existing CouchDB manual setup E2E workflow to assert the
  passphrase and Obfuscate Properties fields are hidden until
  encryption is enabled, then appear, and that the passphrase value
  survives toggling its visibility.
2026-08-26 20:45:44 -06:00
Zeedif caa8c92cbe fix(i18n): keep activateReason default untranslated per review
Commonlib compares this value against the raw "updated" literal to
distinguish automatic activation from a user-requested run, so
translating it changes behaviour rather than just the displayed text.
Keep the default raw and drop the "updated" catalogue entry; the
manually-requested reason stays translated.
2026-08-26 20:41:00 -06:00
vorotamoroz 011a8405b5 Merge pull request #1139 from vrtmrz/1_0_21
Releasing 1.0.21
2026-08-26 20:36:43 +09:00
vorotamoroz f5f7aab11f Polish 1.0.21 release notes 2026-08-26 11:04:15 +00:00
github-actions[bot] 9854319c96 Releasing 1.0.21 2026-08-26 10:43:35 +00:00
vorotamoroz 4ab0689c7f Merge pull request #1138 from vrtmrz/fix/mobile-settings-layout
Fix settings layout on narrow mobile screens
2026-08-26 15:26:08 +09:00
vorotamoroz edeac6f7e2 Separate settings component style helpers 2026-08-26 05:49:37 +00:00
vorotamoroz 783fbb8f23 Refine mobile settings action layout markers 2026-08-26 05:13:38 +00:00
vorotamoroz 8644af6128 Narrow mobile settings layout fix 2026-08-26 04:30:44 +00:00
vorotamoroz 4ff5b4dfe8 Fix mobile settings layout overflow 2026-08-25 17:27:31 +00:00
vorotamoroz 0b1f5ca719 Merge pull request #1137 from vrtmrz/1_0_20
Releasing 1.0.20
2026-08-26 00:06:07 +09:00
vorotamoroz 9de4f2952d Polish 1.0.20 release notes 2026-08-25 14:44:31 +00:00
github-actions[bot] 330f2acd42 Releasing 1.0.20 2026-08-25 14:40:38 +00:00
vorotamoroz 1a84b08de5 Merge pull request #1136 from vrtmrz/fix/declarative-settings-startup-evaluation
Fix premature settings evaluation at start-up
2026-08-25 23:25:13 +09:00
vorotamoroz ce7988fe7c Document settings registration lifecycle 2026-08-25 13:57:04 +00:00
vorotamoroz 537e0e42c3 Fix premature settings evaluation at startup 2026-08-25 13:57:04 +00:00
vorotamoroz a5e1acb960 Merge pull request #1135 from vrtmrz/release/reconcile-1.0.19
Record published 1.0.19 pre-release metadata
2026-08-25 22:55:42 +09:00
vorotamoroz b8191e548d Record 1.0.19 pre-release metadata 2026-08-25 13:25:47 +00:00
vorotamoroz 1c3feb526c Merge pull request #1133 from vrtmrz/refactor/obsidian-api-modernisation
Modernise LiveSync settings for Obsidian's declarative API
2026-08-25 20:14:42 +09:00
vorotamoroz aebf4874b3 Restore npm 10 lockfile compatibility 2026-08-25 11:03:25 +00:00
vorotamoroz 3ea8eac212 Reduce deprecated API and dependency warnings 2026-08-25 10:29:21 +00:00
vorotamoroz f6eefed97c Route pending settings through initialisation choices 2026-08-25 09:12:16 +00:00
vorotamoroz 131ffeb0ef Separate declarative root group identity from display 2026-08-25 07:29:56 +00:00
vorotamoroz 070ce0e307 Reorganise settings around common tasks 2026-08-25 05:34:57 +00:00
vorotamoroz 8adc88b8cb Complete declarative settings runtime integration 2026-08-24 18:11:46 +00:00
vorotamoroz dd11a753d5 Align host integrations with current API recommendations 2026-08-24 16:39:32 +00:00
vorotamoroz 47759f6205 Adapt settings pages to Obsidian's declarative API 2026-08-24 12:17:19 +00:00
vorotamoroz d8f7762d01 Introduce shared setting specifications for Advanced settings 2026-08-24 10:36:39 +00:00
vorotamoroz ebaf89f822 Remove obsolete settings wizard 2026-08-24 10:02:32 +00:00
vorotamoroz 06b9f32bdf Design incremental declarative settings migration 2026-08-24 09:36:16 +00:00
vorotamoroz 32e827692f Merge pull request #1132 from vrtmrz/1_0_18
Releasing 1.0.18
2026-08-24 16:23:00 +09:00
github-actions[bot] cdf6935042 Releasing 1.0.18 2026-08-24 06:45:25 +00:00
vorotamoroz 665a5b5b61 Merge pull request #1131 from vrtmrz/release-notes/database-readiness
Document database readiness and rebuild improvements
2026-08-24 13:23:08 +09:00
vorotamoroz 83faec9280 Add database readiness release notes 2026-08-24 04:18:28 +00:00
vorotamoroz d0bdae2676 Merge pull request #1128 from vrtmrz/update/commonlib-0.1.19-rc.0
Update Commonlib to 0.1.19
2026-08-24 13:10:06 +09:00
vorotamoroz cfbf94a38a Pin Commonlib 0.1.19 stable release 2026-08-24 03:23:59 +00:00
ZeedifandClaude Sonnet 5 2cefff43bb fix(i18n): repair Spanish catalogue placeholders and missing strings
- Fix %{Display language} placeholder mismatch so the language-switch
  notice resolves to the actual language name instead of leaving the
  raw token in the Spanish text.
- Translate the remaining English strings shown in the Setup Wizard
  Intro and CouchDB screens (missing setup/CouchDB copy, "Check server
  requirements", "Create/Connect to database and continue", etc.).
- Translate the Config Doctor's activation reason (previously spliced
  into the Spanish sentence as the raw English word "updated" or
  "you wanted(Thank you)!") by routing it through the catalogue at
  the two call sites that set it.
- Fix Spanish strings that kept English-style Title Case instead of
  sentence case, and route the "OK" button through the catalogue so it
  renders as "Aceptar".

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:23:50 -06:00
vorotamoroz 35e170463e Integrate Commonlib 0.1.19-rc.0 2026-08-23 16:54:23 +00:00
vorotamoroz 694371898a Merge pull request #1127 from vrtmrz/1_0_17
Releasing 1.0.17
2026-08-23 15:22:58 +09:00
nikhilmaddirala 01c060558a Fix existing-device setup guidance 2026-08-18 10:27:05 -04:00
279 changed files with 22270 additions and 9641 deletions
+5 -4
View File
@@ -5,10 +5,11 @@ When working on this repository (writing code, comments, documentation, or commi
## Required Reference Files
Before making changes to documentation, user-facing text, or settings:
1. Read [docs/terms.md](docs/terms.md) for terminology, vocabulary conventions, and technical definitions.
2. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
3. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
4. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
1. Read [docs/terms.md](docs/terms.md) for documentation style and vocabulary conventions.
2. Read [docs/glossary.md](docs/glossary.md) for user-facing, operational, developer, and design terminology.
3. Read [docs/settings.md](docs/settings.md) (and [docs/settings_ja.md](docs/settings_ja.md)) for UI settings and setting key mappings.
4. Read [docs/troubleshooting.md](docs/troubleshooting.md) for troubleshooting guidelines and common recovery steps (such as flag files and SCRAM state).
5. Read [devs.md](devs.md) for development workflows, module architecture, and testing infrastructure.
---
+4 -1
View File
@@ -57,7 +57,10 @@ To maintain consistency across the project, we ask that you follow the establish
- **Affirmative Phrasing**: Avoid asking questions using negative forms in user-facing dialogue. Use affirmative questions to prevent translation and interpretation discrepancies.
- **Specific Words**: Use 'dialogue' for documentation and user-facing messages (use 'dialog' only inside source code). Use the hyphenated form 'plug-in' in user-facing text (use 'plugin' only in configuration settings or technical contexts).
For a detailed list of vocabulary conventions and terms, please refer to [docs/terms.md](docs/terms.md).
For writing conventions, see [Documentation style and vocabulary conventions](docs/terms.md).
Project-specific meanings are defined in the [Project glossary](docs/glossary.md),
including internal developer and design terms which might not appear in the
user interface.
### 3. Translations
+69 -34
View File
@@ -85,7 +85,7 @@ To facilitate development and testing, the build process can automatically copy
Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current Replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
- **Real Obsidian E2E** (`test/e2e-obsidian/`): Local-first scripts that launch real Obsidian with temporary vaults and the built Self-hosted LiveSync plug-in. Use these for boot-up sequence, vault reflection, RedFlag flows, Fast Setup (Simple Fetch), settings dialogues, restart-sensitive workflows, Object Storage regressions, and other behaviour that depends on Obsidian itself. Run focused scripts such as `npm run test:e2e:obsidian:two-vault-sync`, or use `npm run test:e2e:obsidian:local-suite:services` to run the broader local suite with CouchDB and MinIO fixtures managed by the wrapper.
@@ -129,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
@@ -225,32 +238,54 @@ Commonlib owns the typed English fallback for messages requested by its services
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
- This causes pretty significant performance overhead.
#### Diagnostic and notice ownership
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
- Detailed diagnostics may be long and remain in English when they are intended for tracing and the generated report. Include enough context to identify the operation, affected target, and remaining state or retry behaviour.
- The application boundary which owns the workflow should decide whether to raise `LOG_LEVEL_NOTICE`. It has the interaction context to describe the user-visible consequence and the next useful action; an internal stage description alone is not a useful notice.
- When several files fail, issue one concise summary notice after the operation returns. Keep the per-file paths and technical causes at verbose level so that the notice remains readable and the generated report remains traceable.
- Commonlib should raise a notice only when its contract explicitly owns user presentation and no higher-level caller can add the required workflow context.
The ordinary start-up scan provides a concrete comparison:
- Good verbose diagnostic: `Offline scan failed to synchronise ${path} between storage and the local database; this path remains eligible for a later scan.` It identifies the operation, the two states being reconciled, the exact target, and what can happen next. Its length is appropriate for a report.
- Notice which needs more context: `Local database initialisation did not complete. See the log for details.` It describes an internal stage, but does not tell the user whether synchronisation can continue, what may be affected, or how to obtain the detailed log.
- Good application notice for a partial result: `Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.` It states the observable consequence, gives a proportionate action, and leaves the per-file evidence in the report.
- Good application notice for a failed result: `Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.` It states the operational consequence without exposing the internal initialisation stage.
## Common Patterns
### 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
@@ -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.
+1 -1
View File
@@ -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
+21 -7
View File
@@ -1,9 +1,23 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Architectural Decision Record: P2P Room and Transport Lifecycle
## Status
Accepted — implemented and verified through Commonlib owner tests, the Compose transport suite, and the real-Obsidian setup workflow.
The stable P2P service and room-session owner accepted in
[Replicator Capabilities and Lifecycle Orchestration — Part 2](2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
supersede only this record's replaceable LiveSync P2P Replicator and ownership
of the replaceable result returned by the `serviceFeature`. This record remains
authoritative for serialised room operations, `room.leave()`, Trystero-owned
physical peers, and relay reconnection.
## Context
Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signalling, and WebRTC transport. Three related resources have different owners and lifetimes:
@@ -12,7 +26,7 @@ Self-hosted LiveSync uses Trystero's Nostr strategy for P2P discovery, signallin
- Trystero owns the underlying WebRTC peers and may share one physical peer across more than one room; and
- Trystero's Nostr relay manager owns WebSocket clients shared by relay URL.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync replicator from discovering the same remote peer again.
Closing every `RTCPeerConnection` returned by `room.getPeers()` bypasses Trystero's shared-peer manager. The manager may then retain a stale shared peer and prevent a replacement LiveSync Replicator from discovering the same remote peer again.
Room departure and physical transport destruction are not equivalent. `room.leave()` sends the room-leave action, removes that room's actions and callbacks, and detaches its shared-peer binding. Trystero may retain a healthy physical WebRTC peer for later reuse after the last room binding has gone. The retained peer cannot carry actions for the room which has been left.
@@ -40,7 +54,7 @@ The explicit disconnect operation therefore has the following contract:
This operation is a logical LiveSync disconnection and a physical signalling-server disconnection. It does not promise that every browser-owned WebRTC object has been destroyed synchronously.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the current service-feature result at the point of use rather than retaining an obsolete replicator.
An explicit connect resumes relay reconnection before opening a new room. Settings application and database lifecycle replacement close the current LiveSync Replicator, discard it, construct a new instance from the current settings, and open that current instance when the configured policy requires it. Commands, event handlers, and panes resolve the focused views returned by the current P2P `serviceFeature` at the point of use rather than retaining an obsolete Replicator.
Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close requested while an open is in progress must leave no orphan room serving, and repeated opens must not create parallel rooms. No fixed delay is inserted between close and open: readiness is determined by the actual lifecycle operation and peer discovery.
@@ -50,7 +64,7 @@ P2P setup follows the transport's actual ownership model. Initialising the first
## Ownership
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the current Commonlib service-feature result and supplies the platform services used by its current replicator.
Commonlib owns the LiveSync-specific P2P service, RPC, command, and lifecycle composition. Trystero owns WebRTC peer creation, sharing, reuse, stale detection, and destruction, as well as relay-client reconstruction. The Self-hosted LiveSync host owns the focused views returned by the current Commonlib P2P `serviceFeature` and supplies the platform services used by its current Replicator.
Self-hosted LiveSync does not add a separate root Trystero dependency. Tests which must observe relay sockets resolve the exact Trystero generation owned by the locked Commonlib package, avoiding two independent transport singletons in one process.
@@ -58,7 +72,7 @@ Self-hosted LiveSync does not add a separate root Trystero dependency. Tests whi
### Close every value returned by `room.getPeers()`
This bypasses Trystero's shared-peer manager and can prevent a replacement replicator from rediscovering the same peer.
This bypasses Trystero's shared-peer manager and can prevent a replacement Replicator from rediscovering the same peer.
### Add a fixed close-to-open delay
@@ -76,15 +90,15 @@ This interferes with Trystero's shared relay clients. The public pause and resum
Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current Replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The canonical Compose P2P suite uses a real local Nostr relay and WebRTC implementation. It covers ordinary two-peer synchronisation, replacement of the active LiveSync Replicator followed by discovery and transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. The lifecycle scenario is exposed only through a Docker test build and an injected CLI command runner; it is not part of the public CLI command surface.
The real-Obsidian P2P Setup URI workflow creates the first device, generates the second-device URI from it, accepts each peer visibly, and verifies a two-way note round-trip through a local relay. A separate focused pane test covers the principal connection control and teardown without requiring a remote peer. Transport replacement and relay-socket lifecycle remain owned by the package and Compose tests rather than being duplicated in Obsidian.
## Consequences
- Replacing a P2P replicator no longer leaves host views or commands bound to an obsolete instance.
- Replacing a P2P Replicator no longer leaves host views or commands bound to an obsolete instance.
- Explicit signalling-server disconnection has a testable socket-level meaning without claiming immediate destruction of idle WebRTC objects.
- Settings which change the relay, room, passphrase, or TURN configuration can replace the whole LiveSync room safely.
- Trystero may reuse healthy peers across room lifecycles, reducing unnecessary renegotiation.
@@ -72,6 +72,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
### Flag-file recovery order
- For a configured Vault, evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened. An existing unconfigured Vault follows the deferred rule above instead.
- Admit configured-only start-up work at priority 1, after ordinary priority-0 layout integration and before flag-file recovery. An unconfigured Vault offers onboarding and returns `false`, so recovery, compatibility review, database preparation, and configured-only request handling do not run. Treat this admission as a property of the current plug-in process: changing `isConfigured` from `false` to `true` requires the scheduled restart before configured work becomes available, and declining that restart deliberately leaves the current process inert. If an admitted process changes `isConfigured` to `false`, retire the Config Doctor and incomplete-document repair request handlers immediately, and recheck the current setting and database readiness when either handler runs.
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
@@ -100,4 +101,4 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
- Unit and Compose tests verify that ordinary P2P replication observes the policy, explicit P2P rebuild uses the setup bypass, and replacement leaves host actions on the current replicator.
- A real-Obsidian settings test verifies the dedicated summary and details dialogues, captures representative screenshots, confirms that the acknowledged internal version advances only after explicit resume, and confirms that the Change Log contains no acknowledgement control.
- The real-Obsidian CouchDB workflow starts from configured plug-in data without a device-local marker, verifies the copied-or-restored Vault explanation, resumes through the actual dialogue, and then completes remote metadata, chunk, and activity checks. The two-Vault workflow performs the same review once per isolated Vault before reusing the acknowledged device state for later process launches.
- Unit tests fix the layout-ready priority after the three flag-file recovery priorities, so a recovery which stops start-up cannot race the compatibility dialogue.
- Unit tests fix configured Vault admission at priority 1, the three flag-file recovery priorities at 5, 10, and 20, and compatibility review at priority 30. A recovery which stops start-up therefore cannot race the compatibility dialogue.
@@ -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,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
View File
@@ -1,173 +1,222 @@
# Data Structures of Self-Hosted LiveSync
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
## Overview
# Database Data Structures
Self-hosted LiveSync uses the following types of documents:
## Scope and Authority
- Metadata
- Legacy Metadata
- Binary Metadata
- Plain Metadata
- Chunk
- Versioning
- Synchronise Information
- Synchronise Parameters
- Milestone Information
This document is a developer overview of the database structures used by the
current Self-hosted LiveSync 1.0 series. It is not a stable, forward-compatible
API for constructing CouchDB documents by hand.
## Description of Each Data Structure
The executable authority for document types, path and identifier encoding,
chunk splitting and hashing, encryption, compression, and content
reconstruction is the exact `@vrtmrz/livesync-commonlib` version recorded in
the repository lockfile. Commonlib owns this domain under the
[package-boundary decision](adr/2026_07_common_library_package_boundary.md).
When this overview and that installed package differ, correct this document
and treat the package behaviour as authoritative for the affected release.
All documents inherit from the `DatabaseEntry` interface. This is necessary for conflict resolution and deletion flags.
Three representations must be distinguished:
1. the decoded application representation used by Commonlib services;
2. the local PouchDB representation, including CouchDB revision metadata; and
3. the raw remote representation after any configured compression, E2EE, or
path-obfuscation transform.
The examples below describe the first two representations unless a section
explicitly discusses the raw remote representation. The exact raw remote shape
depends on the configured transforms and protocol version and cannot be
inferred from the decoded or local examples alone.
## Principal Document Families
- file Metadata, including compatibility-only legacy Metadata;
- Chunks and compatibility transport structures such as Chunk Packs;
- database version, synchronisation, Milestone, and Node information; and
- CouchDB revision and deletion records.
Commonlib's `EntryDoc` is a union across several of these families. It is not
synonymous with file Metadata.
## Common CouchDB Fields
Database documents share this base shape:
```ts
export interface DatabaseEntry {
_id: DocumentID;
_rev?: string;
_deleted?: boolean;
_conflicts?: string[];
}
```
### Versioning Document
- `_id` identifies one CouchDB document.
- `_rev` identifies one revision of that document.
- `_conflicts` is returned when conflict information is requested. It is
CouchDB revision metadata, not part of the persisted application document.
- `_deleted: true` creates a CouchDB tombstone. It is distinct from the
logical file-deletion field `deleted: true` described below.
This document stores version information for Self-hosted LiveSync.
The ID is fixed as `obsydian_livesync_version` [VERSIONING_DOCID]. Yes, the typo has become a curse.
When Self-hosted LiveSync detects changes to this document via Replication, it reads the version information and checks compatibility.
This internal database version is independent of the plug-in's SemVer version. The last version explicitly acknowledged on a device is stored through Commonlib's device-local configuration contract. When that version differs, or when a settings migration requires review, Self-hosted LiveSync presents a dedicated compatibility dialogue and blocks replication without changing the user's automatic synchronisation choices. A supported upgrade can resume only after explicit review. A downgrade from a newer acknowledged database version, or settings written by a future schema, remains blocked until a compatible plug-in is installed.
Please refer to negotiation.ts.
## File Metadata
### Synchronise Information Document
This document stores information that should be verified in synchronisation settings.
The ID is fixed as `syncinfo` [SYNCINFO_ID].
The information stored in this document is only the conditions necessary for synchronisation to succeed, and as of v0.25.43, only a random string is stored.
This document is only used during rebuilds from the settings screen for CouchDB-based synchronisation, making it like an appendix. It may be removed in the future.
### Synchronise Parameters Document
This document stores synchronisation parameters.
Synchronisation parameters include the protocol version and salt used for encryption, but do not include chunking settings.
The ID is fixed as `_local/obsidian_livesync_sync_parameters` [DOCID_SYNC_PARAMETERS] or `_obsidian_livesync_journal_sync_parameters.json` [DOCID_JOURNAL_SYNC_PARAMETERS].
This document exists only on the remote and not locally.
This document stores the following information.
It is read each time before connecting and is used to verify that E2EE settings match.
This mismatch cannot be ignored and synchronisation will be stopped.
Current files are stored as chunked Metadata. The following is a simplified
shape; the exported Commonlib declarations remain authoritative:
```ts
export interface SyncParameters extends DatabaseEntry {
_id: typeof DOCID_SYNC_PARAMETERS;
type: (typeof EntryTypes)["SYNC_PARAMETERS"];
protocolVersion: ProtocolVersion;
pbkdf2salt: string;
}
```
#### protocolVersion
This field indicates the protocol version used by the remote. Mostly, this value should be `2` (ProtocolVersions.ADVANCED_E2EE), which indicates safer E2EE support.
#### pbkdf2salt
This field stores the salt used for PBKDF2 key derivation on the remote. This salt and the passphrase provides E2EE encryption keys.
### Milestone Information Document
This document stores information about how the remote accepts and recognises clients.
The ID is fixed as `_local/obsidian_livesync_milestone` [MILESTONE_DOCID].
This document exists only on the remote and not locally.
This document is used to indicate synchronisation progress and includes the version range of accepted chunks for each node and adjustment values for each node.
Tweak Mismatched is determined based on the information in this document.
For details, please refer to LiveSyncReplicator.ts, LiveSyncJournalReplicator.ts, and LiveSyncDBFunctions.ts.
```ts
export interface EntryMilestoneInfo extends DatabaseEntry {
_id: typeof MILESTONE_DOCID;
type: EntryTypes["MILESTONE_INFO"];
created: number;
accepted_nodes: string[];
node_info: { [key: NodeKey]: NodeData };
locked: boolean;
cleaned?: boolean;
node_chunk_info: { [key: NodeKey]: ChunkVersionRange };
tweak_values: { [key: NodeKey]: TweakValues };
}
```
### locked
If the remote has been requested to lock out from any client, this is set to true.
When set to true, clients will stop synchronisation unless they are included in accepted_nodes.
### cleaned
If the remote has been cleaned up from any client, this is set to true.
In this case, clients will stop synchronisation as they need to rebuild again.
### Metadata Document
Metadata documents store metadata for Obsidian notes.
```ts
export interface MetadataDocument extends DatabaseEntry {
_id: DocumentID;
type ChunkedMetadata = DatabaseEntry & {
ctime: number;
mtime: number;
size: number;
deleted?: boolean;
eden: Record<string, EdenChunk>; // Obsolete
eden: Record<string, { data: string; epoch: number }>;
path: FilePathWithPrefix;
children: string[];
type: EntryTypes["NOTE_LEGACY" | "NOTE_BINARY" | "NOTE_PLAIN"];
}
```
### type
This field indicates the type of Metadata document.
By convention, Self-hosted LiveSync does not save the mime type of the file, but distinguishes them with this field. Please note this.
Possible values are as follows:
- NOTE_LEGACY: Legacy metadata document
- Please do not use
- NOTE_BINARY: Binary metadata document (newnote)
- NOTE_PLAIN: Plain metadata document (plain)
#### children
This field stores an array of Chunk Document IDs.
#### \_id, path
\_id is generated based on the path of the Obsidian note.
The validation and explicit repair contract for normal-file Metadata whose
actual ID does not match the ID derived from its stored path is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
- If the path starts with `_`, it is converted to `/_` for convenience.
- If Case Sensitive is disabled, it is converted to lowercase.
When Obfuscation is enabled, the path field contains `f:{obfuscated path}`.
The path field stores the path as is. However, when Obfuscation is enabled, the obfuscated path is stored.
When Property Encryption is enabled, the path field stores all properties including children, mtime, ctime, and size in an encrypted state. Please refer to encryption.ts.
### Chunk Document
```ts
export type EntryLeaf = DatabaseEntry & {
_id: DocumentID;
type: EntryTypes["CHUNK"];
data: string;
type: "plain" | "newnote";
};
```
Chunk documents store parts of note content.
`children` contains Chunk document IDs in reconstruction order. A normal save
persists every referenced Chunk before it persists the Metadata which names
those Chunks. The writes are separate database operations rather than one
atomic transaction, so another client may still observe the Metadata first.
The resulting retrieval contract is documented in
[Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md).
- The type field is always `[CHUNK]`, `leaf`.
- The data field stores the chunk content.
- The \_id field is generated based on a hash of the content and the passphrase.
The current persisted file types are:
Hash functions used include xxHash and SHA-1, depending on settings.
Chunking methods used include Contextual Chunking and Rabin-Karp Chunking, depending on settings.
- `plain`, for text content represented by literal text Chunks; and
- `newnote`, for binary content represented by Base64 Chunks.
The compatibility-only `notes` type stores content directly in its `data`
field rather than in `children`. Existing data may be read through selected
legacy paths, but current writers do not create `notes` documents, and not
every current replication path accepts newly created legacy documents.
`datatype` appears on Commonlib's loaded and saving representations. The
current Metadata writer does not persist it, so it is absent from ordinary
current CouchDB Metadata.
`eden` remains in the shared type for existing data compatibility. New
configuration does not enable Eden, and current writers do not create
incubated Eden Chunks for a new configuration.
### Times and Size
`ctime` and `mtime` are Unix epoch times in milliseconds. `size` is the byte
size of the decoded file content supplied by the storage boundary. Current
storage adapters and generated Blob paths obtain it from filesystem metadata
or `Blob.size`; JavaScript `String.length` is a UTF-16 code-unit count and is
not a valid substitute for non-ASCII content.
### Paths, Identifiers, and Namespaces
At the decoded boundary, `path` records the logical path, including any
feature namespace prefix. `_id` is derived from that path by Commonlib's path
service:
- a path beginning with `_` receives a leading `/` in its document ID so that
CouchDB does not interpret it as a reserved identifier;
- the path is folded to lower case only when
`handleFilenameCaseSensitive` is disabled; and
- when path obfuscation is enabled, the body of the document ID is replaced
by an `f:` SHA-256-derived value. A feature prefix is retained, so an
obfuscated Hidden File Sync ID can begin with `i:f:`.
The main namespaces are:
| Prefix | Meaning |
| ------ | ------------------------------------------------------- |
| none | An ordinary Vault file |
| `i:` | Hidden File Sync Metadata |
| `ix:` | Customisation Sync Metadata |
| `ps:` | Compatibility namespace for plug-in storage data |
| `f:` | Obfuscated document-ID body |
| `h:` | Chunk document |
| `h:+` | Chunk whose identifier incorporates encryption material |
Namespaces identify storage and path handling; they do not by themselves
select an Entry `type`. Current Hidden File Sync and Customisation Sync writers
store chunked `plain` or `newnote` documents under `i:` and `ix:`. The
application-local `type: "plugin"` interface is not a Commonlib Entry type and
is not the current Customisation Sync storage format. Although Commonlib
retains an `internalfile` constant for compatibility, current Hidden File Sync
producers do not use it as their persisted type.
The decoded `path` does not become `f:{obfuscated path}`. Compression, E2EE,
and path obfuscation can change raw remote identifiers and properties.
Commonlib owns those transforms, and their exact representation depends on the
selected settings.
The validation and explicit repair contract for ordinary-file Metadata whose
stored `_id` does not agree with its decoded `path` is defined in
[Normal-file Metadata Document ID Validation and Repair](design_docs/metadata_document_id_validation_and_repair.md).
## Chunk Documents
```ts
export type EntryLeaf = DatabaseEntry & {
type: "leaf";
data: string;
isCorrupted?: boolean;
};
```
A `leaf` stores one content-addressed piece. For `plain` Metadata, `data` is
literal text. For `newnote` Metadata, `data` is Base64 text representing binary
bytes. Concatenating and decoding the children in order reconstructs the
decoded file content.
Commonlib's configured `HashManager` produces content-derived Chunk
identifiers. Their representation can vary with compatibility and encryption
settings. Historical hash algorithms remain readable only as compatibility
settings.
Chunk revisions are content-derived irrespective of the obsolete stored
`doNotUseFixedRevisionForChunks` setting. Compression and E2EE may transform a
Chunk's raw remote `data` and add representation markers, so the remote value
can differ from the decoded Chunk data.
## File Deletion
LiveSync distinguishes two operations:
- `deleted: true` is a logical deletion of a file. With Metadata retention
enabled, an ordinary current-file deletion preserves the existing Metadata
fields and Chunk references, updates `mtime`, and creates a new Metadata
revision. This permits the deletion to participate in synchronisation and
conflict history.
- `_deleted: true` is a CouchDB tombstone for one document revision. That
revision does not retain the application body. Tombstones are used by
explicit compatibility and clean-up paths.
A logical deletion does not clear `children` or set `size` to zero. The
`deleteMetadataOfDeletedFiles` setting can request an immediate tombstone
instead. Whether retained, logically deleted Metadata is later tombstoned
depends on the configured deletion-retention settings.
Branch-specific conflict operations and their ancestry requirements are defined
in the [Conflict Resolution specification](specs_conflict_resolution.md).
## Control Documents
The principal control documents are:
| Document | Identifier | Purpose |
| ---------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Version information | `obsydian_livesync_version` | Records the internal database version. The historical spelling is retained for compatibility. |
| Synchronisation information | `syncinfo` | Stores rebuild-related synchronisation information for CouchDB-based operation. |
| CouchDB synchronisation parameters | `_local/obsidian_livesync_sync_parameters` | Stores the protocol version and PBKDF2 salt on the remote. |
| Journal synchronisation parameters | `_obsidian_livesync_journal_sync_parameters.json` | Journal counterpart of the synchronisation-parameter record. |
| Milestone information | `_local/obsidian_livesync_milestone` | Records accepted Nodes, locking, clean-up state, Chunk version ranges, and synchronisation tweak values. |
| Node information | `_local/obsidian_livesync_nodeinfo` | Records the local Node identifier and compatibility markers. |
The `_local/` records are CouchDB-local documents and do not replicate like
ordinary Metadata and Chunks. Synchronisation parameters are checked before
connecting; an incompatible protocol or encryption configuration stops
synchronisation rather than being ignored.
@@ -7,7 +7,7 @@ Accepted for a limited implementation.
## Problem and scope
This document uses the independent revision properties defined under
[Revision](../terms.md#revision) and the general state model in
[Revision](../glossary.md#revision) and the general state model in
[Conflict resolution and revision provenance](../specs_conflict_resolution.md).
Document History can reconstruct an available historical revision from its
@@ -0,0 +1,70 @@
---
date: 2026-09-04
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: unreleased
---
# Path component length compatibility
## Purpose
File systems place limits on each file or folder name, rather than applying one
common limit to an entire Vault-relative path. Those limits are also expressed
in different units. Self-hosted LiveSync therefore treats 255 UTF-8 bytes as a
focused Android and Linux compatibility warning, not as a universal definition
of a valid path.
## Basis for the 255-byte warning
- The Linux kernel documentation gives ext4 a maximum file-name length of
[255 bytes](https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html).
- The F2FS on-disk header defines
[`F2FS_NAME_LEN` as 255](https://android.googlesource.com/kernel/common/+/88d92fb1c034922572bab93482ac9cc61d4ba43c/include/linux/f2fs_fs.h)
and stores names in byte arrays.
- Android's MediaProvider uses a
[`MAX_FILENAME_BYTES` value of 255](https://android.googlesource.com/platform/packages/providers/MediaProvider/+/bae279463/src/com/android/providers/media/util/FileUtils.java)
when building file names. Its source notes that emulated storage can write to
ext4 through FUSE, where names are encoded as UTF-8.
- Android 11 and later use
[FUSE for emulated storage](https://source.android.com/docs/core/storage/fuse-passthrough),
with requests passing through to the underlying file system.
Together, these provide a conservative compatibility boundary for file names
which may reach Android or Linux storage. They do not show that every Android
device, storage provider, or Linux file system has the same limit.
## Why the rule is not universal
Other platforms describe component limits differently. Microsoft's file-system
comparison documents limits in
[Unicode characters](https://learn.microsoft.com/en-us/windows/win32/fileio/filesystem-functionality-comparison),
not UTF-8 bytes. Apple's HFS Plus format stores a name as up to
[255 16-bit `UniChar` values](https://developer.apple.com/library/archive/technotes/tn/tn1150.html).
Apple's APFS guidance discusses valid UTF-8 names, normalisation, and case
sensitivity, but does not establish a universal
[255-byte component rule](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html).
A name can consequently exceed 255 UTF-8 bytes and still work on one platform,
or fail for another platform-specific reason while remaining below this
boundary.
## Product policy
Self-hosted LiveSync applies the warning as follows:
1. split the Vault-relative path on `/` and inspect each non-empty component;
2. measure each component after UTF-8 encoding;
3. accept 255 bytes without this warning and warn at 256 bytes or more;
4. identify every over-limit file or folder name in the active-file status;
5. do not reject, truncate, or rename the path; and
6. treat the result of the real storage operation as authoritative.
If a scan cannot process an individual file, its path is recorded in the
verbose log and remains eligible for a later retry. Ordinary start-up may still
become ready so that unaffected files can synchronise. Explicit Fetch and
Rebuild operations retain strict scan completion because they establish an
authoritative local or remote state.
This policy does not replace the existing checks for reserved characters,
case collisions, ignore rules, or configured file-size limits.
+333
View File
@@ -0,0 +1,333 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
commonlib-source-commit: e770f617ff0fc88f4823226b0ab3aefdff50cc1e
status: accepted
---
# Replicator architecture
This is the implemented architecture for Self-hosted LiveSync 1.0.24 with `@vrtmrz/livesync-commonlib` 0.1.21. It is an implementation overview for developers maintaining the composition or adding a built-in provider. The corresponding Commonlib source was inspected at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`.
## Status, scope, and source-of-truth boundary
The plug-in repository is the source of truth for host composition, host scheduling, Obsidian/CLI/WebApp/WebPeer integration, provider declarations, and host-owned resource adapters. Commonlib is the source of truth for the provider contract, the active-publication state machine, typed replication runners, P2P service ownership, and Journal transport primitives. This repository consumes Commonlib as the published `0.1.21` package; it does not maintain a source mirror or a generated fallback.
The implementation has three built-in providers:
- CouchDB, composed by this repository;
- Object Storage, composed by this repository; and
- P2P, composed by Commonlib's `useP2PReplicatorFeature` in each application runtime which supports it.
The catalogue is closed at host composition. `src/common/replicatorProviders.ts` exhaustively composes the central providers, while the Commonlib P2P feature composes its P2P provider. This is not a runtime third-party registry: a provider cannot be added by registering a name, loading a plug-in, or supplying a setting at runtime. Adding a provider means changing the relevant Commonlib and host composition, then shipping and testing that composition.
The three capability ADRs record the decisions which led to this shape. They are useful decision history, but this document describes the current implementation and its ownership boundaries rather than repeating the ADR sequence.
## Terminology
The [Project glossary](../glossary.md#developer-and-design-terms) is canonical
for project-specific vocabulary in this document. In particular, see
[Replicator](../glossary.md#replicator),
[Replicator provider definition](../glossary.md#replicator-provider-definition),
[Active publication](../glossary.md#active-publication),
[Admission and reservation](../glossary.md#admission-and-reservation),
[Configuration identity](../glossary.md#configuration-identity),
[Capability](../glossary.md#capability),
[Central remote](../glossary.md#central-remote),
[Publication retirement](../glossary.md#publication-retirement), and
[Fence, generation, and epoch](../glossary.md#fence-generation-and-epoch).
The glossary also fixes the ownership meanings of
[Adjunct P2P transport](../glossary.md#adjunct-p2p-transport),
[Remote resource and probe](../glossary.md#remote-resource-and-probe),
[Non-owning adapter](../glossary.md#non-owning-adapter),
[P2P service, room session, and demand](../glossary.md#p2p-service-room-session-and-demand),
[Interaction authority](../glossary.md#interaction-authority),
[Journal remote epoch](../glossary.md#journal-remote-epoch),
[Replication outcome](../glossary.md#replication-outcome), and
[Suspension](../glossary.md#suspension).
The user-facing glossary also distinguishes
[OneShot Sync from Continuous replication](../glossary.md#user-facing-and-operational-terms).
Names shown in code font are source identifiers, not additional prose terms.
## Ownership and topology
The portable topology below names ownership rather than merely call order. An arrow means that the object or layer composes, invokes, or owns the next item.
```text
Application composition
Obsidian main / CLI / WebApp --> LiveSyncBaseCore --+
WebPeerRuntime ------------------------------------+--> Service Hub
|
+----------------------------------------+---------------------+
| |
v v
ReplicatorService (Commonlib) ReplicationService (Commonlib)
| |
+--> closed provider catalogue +--> typed replication runners
| CouchDB / Object Storage / P2P | readiness + outcomes
| |
+--> active publication <------ exact-publication admission ---+
provider + instance + identity
useP2PReplicatorFeature (Commonlib)
|
+--> registers the P2P provider, whose factory creates non-owning active adapters
|
+--> stable P2P service views and lifecycle
|
+--> P2PRoomSessionOwner
|
+--> P2PAutomationCoordinator
+--> current P2PRoomSession
|
+--> P2PHost / TrysteroReplicator
|
+--> Trystero room, relays, and physical peers
```
`LiveSyncBaseCore` receives a Service Hub, registers the central provider definitions, composes `serviceFeature` functions, and retains only focused views. `WebPeerRuntime` composes directly over its browser Service Hub because it is a P2P-only host. The P2P feature is composed by each supporting application runtime, so the CLI and web applications can use the same transport ownership without making the active adapter a second owner.
| Owner | Responsibility | Explicitly does not own |
| --- | --- | --- |
| Host composition | Selects the closed provider catalogue, supplies host adapters, and registers features before lifecycle work begins. | Active-instance retirement or operation admission. |
| Commonlib `ReplicatorService` | Provider registration, active publication, exact-publication reservations, serial lifecycle transitions, transfer stop during suspension or retirement, and physical close. | Replication readiness, trigger policy, or P2P room ownership. |
| Commonlib `ReplicationService` and its typed coordinator | User and unattended OneShot dispatch, Continuous startup, readiness, interaction authority, finite-activity accounting, outcomes, and failure hand-off. | Active publication replacement or scheduling policy. |
| Host replication scheduling `serviceFeature` | Decides when resume, Periodic, and Continuous requests may run, and fences stale scheduled work. | Provider construction, transfer mechanics, or active Replicator close. |
| Provider definition and runner | Declares what one remote kind supports and adapts its transfer results to typed outcomes. | Selecting when a request should run. |
| Stable P2P service and room-session owner | P2P demand, binding reconciliation, session retirement, finite room operations, automation state, and focused views. | The active P2P adapter's publication lifetime or Trystero's physical peers. |
## Request flow
1. Composition registers the central provider definitions before lifecycle-driven provider initialisation. P2P composition registers its own Commonlib provider and returns stable P2P views and lifecycle controls.
2. `ReplicatorService` serialises setting realisation, active-provider initialisation, database lifecycle transitions, suspension, and unload. `ReplicationService` owns per-request readiness. Resume work is separately owned by the host scheduling feature and the P2P service lifecycle.
3. `ReplicatorService` resolves the current `remoteType`, checks `isConfigured`, and computes the provider's opaque configuration identity. If the provider and identity are unchanged, the active publication is retained. If either changes, the replacement fence runs before a new publication is created.
4. A typed operation such as `runUserInitiated`, `runUnattended`, or `startContinuous` acquires the current publication after earlier queued lifecycle transitions have settled. It checks interaction authority, capability support, and readiness in the order required by the request type, then takes an immutable settings snapshot.
5. The operation admits a reservation against the exact publication and identity. The provider-specific runner owns the transfer; finite operations are counted as bounded remote activity, while continuous replication is not.
6. Provider resources are created through the declared resource factories when a feature needs a connection, preferred-tweak, Security Seed, or synchronisation-information probe. Resource ownership is explicit, and owned resources are disposed in the caller's `finally` path.
7. The runner returns a `ReplicationOutcome` rather than using `undefined` as a success signal. Documents delivered through `parseSynchroniseResult` are queued by the host result processor for local application; central compatibility recovery and Security Seed preflight remain host features around the typed operation.
8. Automatic `database-event`, `editor-save`, `file-open`, `merge`, `resume`, `periodic`, and `daemon` requests select unattended authority explicitly. Unattended work cannot prompt, select a peer interactively, or silently fall back to a legacy capability.
The active publication may change while a request is being prepared. The reservation keeps the admitted old instance alive until the operation settles; a new request admitted after the queued transition observes the new publication. A callback which holds a reservation must not await a lifecycle transition which itself waits for that reservation.
## Provider contract and current capability matrix
The Commonlib contract is deliberately small. A provider definition supplies the following shape (with the exact generic types omitted here for readability):
```typescript
{
kind,
diagnosticName,
readiness,
isConfigured(setting),
configurationIdentity(setting),
create(setting),
remoteResources,
centralRemoteAdministration?,
userInitiatedOneShot,
unattendedOneShot,
continuous,
stopActiveTransfer
}
```
`defineReplicatorProviderDefinitions` makes the definition map exhaustive for the selected `RemoteType` tuple and rejects duplicate, missing, extra, or mismatched runtime definitions. Capability declarations are explicit. `supported` adapts a provider runner to `ReplicationOutcome`; `not-implemented` and `not-applicable` produce typed blocked outcomes. The contract distinguishes user authority from `NO_INTERACTION`, so a provider cannot accidentally prompt from an unattended trigger.
The factory receives the fully merged effective settings. It returns a `ReplicatorInstance` with only four required lifecycle methods:
| Method | Contract |
| --- | --- |
| `initializeDatabaseForReplication()` | Prepare local state before publication. `false` rejects and disposes the candidate. |
| `openReplication(setting, keepAlive, showResult, ignoreCleanLock)` | Retained compatibility entry point for finite or Continuous work. A typed provider runner must convert its `void` or Boolean settlement to an explicit outcome; `void` is not finite success. |
| `terminateSync()` | Request cancellation of active transfer work and settle synchronously or asynchronously. It does not transfer ownership or replace physical close. |
| `closeReplication()` | Release resources owned by this instance. It runs only after admitted work drains; a non-owning adapter, such as P2P, must leave service-owned resources alone. |
Provider-specific methods remain on provider-specific interfaces or host-owned adapters. They are not added to the generic contract merely because an old compatibility class exposed them.
### Current capability matrix
| Capability | CouchDB | Object Storage | P2P |
| --- | --- | --- | --- |
| Readiness | Central remote preparation required | Central remote preparation required | Central preparation not applicable; peer readiness is provider-owned |
| Active Replicator factory | `LiveSyncCouchDBReplicator` | `LiveSyncJournalReplicator` | `P2PActiveReplicatorAdapter` over the stable P2P service |
| User-initiated OneShot | Supported | Supported | Supported, with explicit peer selection |
| Unattended OneShot | Supported | Supported | Supported for configured targets; no peer-selection prompt |
| Continuous replication | Supported | Not applicable | Not applicable |
| Stop active transfer | Supported | Supported | Supported; cancels finite operations while retaining the room when appropriate |
| Connection resource | Supported | Supported | Not applicable |
| Preferred-tweak resource | Supported | Supported | Not applicable |
| Security Seed resource | Supported | Supported | Not applicable |
| Synchronisation-information resource | Supported | Not applicable | Not applicable |
| Central remote administration | CouchDB administration supported | Object Storage administration supported | Not applicable |
The matrix is the current host composition, not a promise that every provider must support every row. A new provider must declare every resource kind and every operation capability, using `not-applicable` where the concept does not exist. Central remote administration is a cohesive capability: it covers the applicable verification milestone and mark-resolved, lock, and unlock mutations rather than exposing individual legacy helpers as generic operations.
## Active Replicator lifecycle and exact replacement fence
Commonlib's `ReplicatorService` serialises lifecycle transitions on one queue. It owns the active publication, reservations against that publication, transfer stop, and final close. The effective configuration identity is deliberately opaque; comparing it is valid, but inspecting or persisting it is not.
```text
absent
|
| configured lifecycle initialisation
v
candidate (private and not admitted)
| \
| initialised and current \ failed or stale --> closed; no active publication
v
active publication
| \ same provider and identity --> retained
| \ suspension ----------------> transfer stopped; publication retained
|
| provider, identity, database, or terminal lifecycle change
v
quiescing (removed from current; new admission fenced)
|
| stop --> drain exact reservations --> close --> complete retirement
v
absent --> optional replacement candidate
```
For a provider or effective-configuration change, the exact fence is:
1. Enqueue the transition on the serial lifecycle queue.
2. Read the current setting, resolve the closed provider definition, check `isConfigured`, and compute the effective identity.
3. If the current publication has a different provider or identity, call `beginRetirement`. This stops new reservations and removes the publication from the current slot.
4. Request the provider's `stopActiveTransfer` capability. The legacy `terminateSync` path is retained only for an untyped compatibility publication.
5. Await settlement of reservations admitted from the retiring publication. New operations cannot enter it.
6. Call the old instance's `closeReplication`.
7. Mark the retirement complete. No publication is installed between removal of the old publication and this completion.
8. Create a candidate through the provider definition, reset provider statistics, and yield the required microtask boundary.
9. Initialise the candidate for the current database and run `onBeforeReplicatorPublication` handlers.
10. Re-read the current setting, provider, and opaque identity. If any is stale, dispose the candidate and publish nothing; the queued lifecycle work will resolve the newer state.
11. Publish the provider, candidate instance, and identity atomically as the new active publication.
The same provider and identity retain the current publication. Database initialisation is a lifecycle boundary even when the setting identity is unchanged: the old publication is retired before the physical local database is replaced, then a candidate is created for the new database. A failed or stale candidate leaves the service without an active publication; it is not silently substituted with a previous instance.
If transfer stop fails, retirement still proceeds to draining and physical close. If physical close rejects, retirement remains fenced and no replacement can be published; a later serial transition may retry the same retirement. The service never restores admission to a publication once retirement has begun.
## Generation and epoch fences
These values protect different state machines. They must not be collapsed into one general-purpose generation.
| Fence | Owner and representation | Changes when | Protects | Does not protect |
| --- | --- | --- | --- | --- |
| Active publication identity | Commonlib `ActiveReplicatorPublication` object, containing provider, instance, and opaque configuration identity; not a numeric counter | Provider/configuration replacement, or a database lifecycle replacement | Admission and completion against the exact active instance | Delayed scheduling, P2P automation baselines, or Journal remote-wipe decisions |
| Host scheduling lifecycle generation | `ReplicationSchedulingContext.lifecycleGeneration` | Scheduling resumes after the lifecycle was disabled | Resume operations and timer callbacks from a previous host scheduling lifecycle | Provider replacement, P2P room callbacks, or Journal transfers |
| P2P service lifecycle generation | Private `P2PServiceState.lifecycleGeneration` | Explicit disconnect or host lifecycle closure | Delayed P2P AutoStart and service-level automatic demand | A room's operation set, automation baseline, or remote Journal epoch |
| P2P session object / epoch fence | The current `P2PRoomSession` object, its `acceptingOperations` flag, session abort signal, and the owner's lifecycle queue; there is no exported numeric P2P session epoch | Room binding replacement, retirement, or owner close | Room callbacks, finite operations, peer handlers, and stale candidate sessions | Cross-session automation deduplication and host scheduling |
| P2P automation generation | `P2PAutomationCoordinator.generation` | `beginLifecycle` or effective identity reconciliation (namespace or database object) | Completed peer baseline publication and stale automation completions | Room ownership, explicit disconnect veto, and physical peer connection ownership |
| Journal stop generation | `LiveSyncJournalReplicator.journalTransferStopGeneration` | `terminateSync` requests a stop | Admitted Journal transfers after setup and before `client.sync`; repeated stops share settlement | Provider publication identity and remote checkpoint/cache identity |
| Journal remote epoch | `CheckPointInfo.journalEpoch`, derived as `protocolVersion:pbkdf2salt` | Successfully read sync parameters yield a different value; a subsequent history probe decides whether checkpoint caches must be reset | Journal checkpoint and deduplication-cache reconciliation across remote histories | Local cancellation, provider retirement, or transfer admission |
In particular, a numeric value in one row cannot be used as evidence that an operation in another row is current. Failure to read Journal sync parameters does not produce a new remote epoch, and a P2P transport replacement does not by itself clear the automation coordinator's completed-peer baseline.
## Suspension, terminal retirement, and database replacement
| Event | `ReplicatorService` publication | P2P service and adapter | Result |
| --- | --- | --- | --- |
| Application suspension | Requests `stopActiveTransfer` and retains the active publication | `closeForLifecycle` closes the current room/session and invalidates delayed automation; the active adapter is non-owning and does not close the service through `closeReplication` | Suspension is reversible. Resumption schedules the appropriate P2P AutoStart and host replication work. |
| Provider setting or effective identity change | Runs the complete replacement fence | Reconciles or replaces the P2P room when its effective binding changes | The old publication/session cannot receive new work. |
| Database replacement or rebuild | Retires and closes the active publication before physical database teardown; database-ready events permit reinitialisation | Closes the P2P room before database destruction and creates a binding for the new database object | No provider or room may retain the old database. |
| Unload or terminal lifecycle close | Stops, drains, closes, and completes retirement; no active publication remains | `closeForLifecycle` clears owner demand, closes the current room, and invalidates automation | Terminal retirement is not resumed. |
Suspension and retirement therefore have different guarantees. `ReplicatorService` suspension stops transfer but intentionally retains the provider instance and publication. Terminal retirement removes admission, drains it, closes it, and does not publish a replacement unless a later lifecycle event explicitly initialises one. P2P transport is additionally closed on suspension because its service lifecycle owns a room session, but the active P2P adapter is only a compatibility handle and does not own that session.
## Owned resources and probes
| Resource or probe | Owner | Lifetime and disposal rule |
| --- | --- | --- |
| Active Replicator publication | Commonlib `ReplicatorService` | Publication retirement fences admission, drains reservations, calls `closeReplication`, and completes the retirement. |
| Central connection probe | Host resource factory in `src/common/replicatorResources/connection.ts` | A caller-owned CouchDB or Object Storage snapshot backed by a concrete Replicator/connection; dispose it in `finally`. It does not replace the active publication. |
| Preferred-tweak probe | Host `preferredTweak` resource factory | Read through the declared resource, then dispose the owned resource. |
| Security Seed probe | Host `securitySeed` resource factory and the replication preflight | Use `createRemoteResource` and `withOwnedRemoteResource`; reject an empty seed and always dispose the resource. It does not assume that an active Replicator exists. |
| Synchronisation-information probe | Host `synchronisationInformation` resource factory | Check or read through the resource and dispose it; an unavailable remote is not treated as a confirmed absence. |
| Central administration operation | Provider administration runner | Uses its declared verification and mutation ownership. A fresh connection may be owned by the operation; an active Journal client borrowed from the active provider is not disposed by the borrower. |
| Journal client and transfer set | `LiveSyncJournalReplicator` | The Journal Replicator owns the client and active transfer promises. `terminateSync` requests stop and awaits the shared settlement; `closeReplication` disposes the client without lazily creating one. |
| P2P room/session, finite operation set, and relay actions | `P2PRoomSessionOwner` and current `P2PRoomSession` | The owner serialises binding and demand changes. Session retirement rejects admission, aborts and awaits operations, disables broadcast, and disposes the session Replicator. |
| Physical Trystero peer connections | Trystero runtime | Commonlib must not close raw `room.getPeers()` connections merely because a logical room session retires; shared Trystero ownership may outlive an idle room callback. |
| Physical local database | Commonlib DatabaseService | Database lifecycle owns teardown and readiness. Replicator and P2P owners close before the database is destroyed. |
Probe callers must use the resource capability rather than reaching through `LiveSyncBaseCore.replicator`. This keeps a short-lived observation from acquiring ownership of the active transfer or publication.
## P2P special ownership and the non-owning adapter
P2P is composed as a `serviceFeature` and has more state than a central provider. It can remain enabled as an adjunct while CouchDB or Object Storage is the selected main remote; `ReplicatorService` publishes the P2P active adapter only when `remoteType` is P2P. The active-publication owner and the room-session owner are therefore deliberately independent.
The stable service owns persistent demand (`explicit`, `automatic`, or `rebuild-continuation`), finite-operation demand, the lifecycle queue, the effective binding, and the current room session. The room owner compares the local database object separately and includes the effective device name in its binding signature, so the binding is not interchangeable with the P2P provider's active-publication configuration identity. The automation coordinator owns automation-baseline deduplication. The current `P2PRoomSession` owns one room, peer handlers, advertisements, RPC, session cancellation, and finite operations. Trystero owns shared relay clients and physical peer connections.
`P2PActiveReplicatorAdapter` implements the minimal `ReplicatorInstance` view required by the provider contract. Its `initializeDatabaseForReplication`, `openReplication`, and `terminateSync` methods delegate to the P2P service. Its `closeReplication` is intentionally a no-op: closing the active adapter must not close the room, relay actions, finite-operation registry, or stable P2P service. The service lifecycle (`closeForLifecycle`, owner close, or binding reconciliation) is the only owner which retires the room.
The P2P connection probe follows the same boundary. An active compatible room may be observed; an incompatible active binding blocks the probe. An idle probe can run a caller-owned trial through the owner queue and must await clean-up. It must not publish itself as the active room or close resources owned by another session.
Automatic configured-target replication acquires finite room demand, waits for peer advertisements within a bounded window, evaluates admission without prompting, shares the baseline through `P2PAutomationCoordinator`, and returns an explicit completed, partial, blocked, cancelled, or failed outcome. Explicit disconnect veto remains distinct from host lifecycle closure. AutoStart cannot clear an explicit disconnect veto; rebuild continuation is a separately authorised path.
## Adding a built-in provider
Provider work crosses the Commonlib package boundary and this repository's host composition. The following sequence is the smallest complete path; omit a step only when the provider genuinely has no corresponding concept.
### First decide whether this is a provider
A new provider is appropriate when a remote kind needs a distinct active Replicator lifecycle, effective configuration identity, readiness policy, or replication roles. A new S3-compatible service or another backend which retains the Object Storage Journal protocol is usually an `IJournalStorage` adapter instead; see [Journal Replicator 2nd Edition](../design_docs_of_journalsync_2nd.md). A new read-only observation over an existing provider is usually a remote resource or a focused view. Neither case needs another active provider.
1. **Define the canonical remote kind in Commonlib.** Add the `RemoteType` value and its setting type in `src/common/models/setting.const.ts` and `src/common/models/setting.type.ts`, update exports such as `src/common/types.ts`, and add defaults or persistence fields only where the provider needs them. Add focused setting and migration tests.
2. **Implement the Replicator in Commonlib.** Place the provider-specific Replicator and transport code under `src/replication/<provider>/`. Implement the minimal `ReplicatorInstance` contract, cancellation, and close semantics. Keep provider-specific operations on focused facets. Add unit tests for success, cancellation, failure, stop, and replacement-sensitive clean-up.
3. **Define effective configuration identity.** Include every setting which changes the live binding, and exclude profile labels or policy-only settings. Normalise two spellings only when the runtime genuinely treats them as equivalent. Put shared identity logic in Commonlib when the provider is shared there; put the host projection in `src/common/replicatorConfigurationIdentity.ts` when this repository owns it. Test that equivalent effective settings retain an instance and binding changes replace it. Never expose identity values in logs, UI, or persistence.
4. **Add configuration and setup seams in Commonlib.** If the provider has a connection string, profile, migration, or document representation, update the applicable files, including `src/common/ConnectionString.ts`, `src/remoteConfigurations.ts`, `src/common/configForDoc.ts`, `src/API/processSetting.ts`, and their focused tests. These files are conditional: do not add a setting representation which the provider does not need.
5. **Declare the provider contract surface at its composition owner.** Add a central provider to the tuple and definition map in this repository's `src/common/replicatorProviders.ts`; add a P2P-like provider to the closed tuple owned by its Commonlib `serviceFeature`. In the definition, declare readiness, all four remote-resource capability entries, user and unattended OneShot runners, Continuous where applicable, `stopActiveTransfer`, and central administration where applicable. Extend `RemoteResource` kinds only for a genuinely cross-provider resource; do not encode provider-specific helpers as generic capabilities.
6. **Implement stateful transport ownership, if required.** For a P2P-like provider, add a stable service owner, focused views, lifecycle ownership, binding identity, session retirement, and automation fences in Commonlib, then compose it through a `serviceFeature`. Keep any active adapter non-owning if the service owns a replaceable transport. Add lifecycle, stale-callback, probe, and database-replacement tests before host integration.
7. **Compose the provider in each supporting application.** Central definitions are registered by `LiveSyncBaseCore`. A dedicated stateful feature must be composed from the applicable hosts: `src/main.ts`, `src/apps/cli/main.ts`, `src/apps/webapp/WebAppRuntime.ts`, and `src/apps/webpeer/src/WebPeerRuntime.ts`. Each selected catalogue must remain exhaustive and closed; add no runtime provider registry.
8. **Add host-owned resources and administration.** Add or extend `src/common/replicatorResources/` for connection, preferred-tweak, Security Seed, and synchronisation-information probes. Add provider-specific central verification and mutations in `src/common/centralRemoteAdministration.ts` when applicable. Test ownership, snapshots, the distinction between unavailable and absent states, postconditions, and disposal.
9. **Integrate setup and user-facing configuration.** Update the relevant setup dialogue files under `src/modules/features/SetupWizard/dialogs/`, including `dialogs/setupDialogTypes.ts`, SetupManager or setup features, remote configuration handling, and message resources under `src/common/messagesYAML` plus generated baked messages where required. Follow the terminology and settings mappings in the repository documentation, and add setup, serialisation, and migration tests.
10. **Integrate host operations and triggers.** Adapt only the capability call sites which the provider supports. Check `src/serviceFeatures/replicationScheduling.ts`, `src/serviceFeatures/replication/`, CLI commands under `src/apps/cli/commands`, and application-specific lifecycle composition. Ensure unattended paths use `NO_INTERACTION`, periodic and resume fallback obey capability outcomes, and no caller reaches for `getNewReplicator` or the `LiveSyncBaseCore.replicator` compatibility getter.
11. **Validate the package boundary.** In Commonlib, run its focused unit tests, build or pack the exact candidate artefact, and test the downstream LiveSync consumer against that artefact. In this repository, run provider map, configuration-identity, resource, central-administration, scheduling, and replication-feature unit tests. Add a real remote integration test, CLI E2E coverage, or real Obsidian E2E coverage for every boundary the provider claims to support.
A provider is complete only when its source ownership, replacement fence, capability matrix, setup path, and tests agree. Updating a setting type or adding a class without adding the closed composition definition does not make it a built-in provider.
## Compatibility seams and non-goals
- `ReplicatorService.getNewReplicator`, `getActiveReplicator`, and the `LiveSyncBaseCore.replicator` getter remain compatibility seams for existing callers. Beyond `ReplicatorInstance`, `LiveSyncBaseCore.replicator` exposes provider-specific members only as an optional compatibility view. None is a new provider extension point.
- `ReplicationService.performReplication` remains a direct legacy path through the active instance. New call sites use `replicateUserInitiated`, `replicateUnattended`, `replicateUnattendedByEvent`, `startContinuous`, or `stopActiveTransfer`, as appropriate.
- `LiveSyncAbstractReplicator` and other legacy classes may retain methods needed by existing modules. New features use typed provider capabilities, resource factories, and focused service views; they do not infer capabilities from a large legacy class.
- The generic contract does not unify directional Journal operations, Streaming replication, Chunk retrieval, remote-size inspection, garbage collection, repair workflows, or provider-specific administration. Those remain explicit provider or host features.
- The provider catalogue is not a public runtime registry, dynamic plug-in API, or settings-driven discovery mechanism. Unknown `RemoteType` values are composition/configuration failures, not third-party providers which the runtime should load.
- Commonlib remains an external authoritative package. This repository must not recreate `src/lib`, `_types`, or another source mirror to bypass the package boundary.
- P2P logical room retirement does not authorise closing shared raw `RTCPeerConnection` objects. Physical transport ownership remains with Trystero.
- No numeric P2P session epoch is exported. Session object identity, admission flags, abort signals, and the owner queue provide the fence; the P2P automation generation and the host scheduling generation protect different concerns.
- Suspension is not a database replacement or a successful replication result. It stops or closes the appropriate active work and relies on the next lifecycle event to resume, reconcile, or retire it.
## Source map
### This repository
| Concern | Source and tests |
| --- | --- |
| Host composition and compatibility boundary | [`LiveSyncBaseCore.ts`](../../src/LiveSyncBaseCore.ts), [`main.ts`](../../src/main.ts), [`src/apps/cli/main.ts`](../../src/apps/cli/main.ts), [`WebAppRuntime.ts`](../../src/apps/webapp/WebAppRuntime.ts), [`WebPeerRuntime.ts`](../../src/apps/webpeer/src/WebPeerRuntime.ts) |
| Closed central provider map | [`replicatorProviders.ts`](../../src/common/replicatorProviders.ts), [`replicatorProviders.unit.spec.ts`](../../src/common/replicatorProviders.unit.spec.ts) |
| Provider identities and resources | [`replicatorConfigurationIdentity.ts`](../../src/common/replicatorConfigurationIdentity.ts), [`replicatorResources/`](../../src/common/replicatorResources/index.ts), [`replicatorResources.unit.spec.ts`](../../src/common/replicatorResources.unit.spec.ts) |
| Central administration and preflight | [`centralRemoteAdministration.ts`](../../src/common/centralRemoteAdministration.ts), [`centralRemoteAdministration.unit.spec.ts`](../../src/common/centralRemoteAdministration.unit.spec.ts), [`replication/preflight.ts`](../../src/serviceFeatures/replication/preflight.ts) |
| Host scheduling and replication feature | [`replicationScheduling.ts`](../../src/serviceFeatures/replicationScheduling.ts), [`replicationScheduling.unit.spec.ts`](../../src/serviceFeatures/replicationScheduling.unit.spec.ts), [`replication/index.ts`](../../src/serviceFeatures/replication/index.ts) |
| Service graph and bounded local activity | [`ObsidianServices.ts`](../../src/modules/services/ObsidianServices.ts), [`ObsidianServiceHub.ts`](../../src/modules/services/ObsidianServiceHub.ts) |
| Architecture guidance | [`devs.md`](../../devs.md), [Service feature and legacy Module boundaries](service_feature_and_legacy_module_boundaries.md), [Project glossary](../glossary.md), [Documentation style and vocabulary conventions](../terms.md), [`docs/settings.md`](../settings.md), [`docs/troubleshooting.md`](../troubleshooting.md) |
### Commonlib 0.1.21
The exact package tree described here is [pinned at commit `e770f617ff0fc88f4823226b0ab3aefdff50cc1e`](https://github.com/vrtmrz/livesync-commonlib/tree/e770f617ff0fc88f4823226b0ab3aefdff50cc1e). The source and design-document links below target that commit.
| Concern | Commonlib source or design document at the pinned commit |
| --- | --- |
| Provider contract, outcomes, identities, and resource capabilities | [`src/replication/ReplicatorInstance.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorInstance.ts), [`src/replication/ReplicatorProvider.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/ReplicatorProvider.ts), [`src/replication/RemoteResource.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/RemoteResource.ts), [`src/replication/CentralRemoteAdministration.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralRemoteAdministration.ts), [`src/replication/CentralCompatibility.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/CentralCompatibility.ts) |
| Active publication and typed operations | [`src/services/base/ReplicatorService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.ts), [`activeReplicatorState.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicatorService.activeReplicatorState.ts), [`typedReplication.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.typedReplication.ts), [`readiness.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/services/base/ReplicationService.readiness.ts) |
| P2P service, room ownership, and automation | [`P2PService.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/p2p/P2PService.ts), [`P2PRoomSessionOwner.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSessionOwner.ts), [`P2PRoomSession.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PRoomSession.ts), [`useP2PReplicatorFeature.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/useP2PReplicatorFeature.ts), [`P2PAutomationCoordinator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/trystero/P2PAutomationCoordinator.ts) |
| P2P lifecycle design | [`docs/p2p-transport-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/p2p-transport-lifecycle.md) |
| Database and service-feature lifecycle | [`docs/database-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/database-lifecycle.md), [`docs/service-feature-composition.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/service-feature-composition.md), [`docs/settings-lifecycle.md`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/docs/settings-lifecycle.md) |
| Journal transfer and remote epoch | [`LiveSyncJournalReplicator.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/LiveSyncJournalReplicator.ts), [`JournalSyncCore.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncCore.ts), [`JournalSyncTypes.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/JournalSyncTypes.ts) |
| Journal storage adapter boundary | [`JournalStorageAdapter.ts`](https://github.com/vrtmrz/livesync-commonlib/blob/e770f617ff0fc88f4823226b0ab3aefdff50cc1e/src/replication/journal/objectstore/JournalStorageAdapter.ts) |
### Decision records
- [Core provider contract and capabilities ADR](../adr/2026_08_replicator_capabilities_01_core_contract.md)
- [P2P service lifecycle ADR](../adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md)
- [Replicator migration plan ADR](../adr/2026_08_replicator_capabilities_03_migration_plan.md)
- [P2P Room and Transport Lifecycle ADR](../adr/2026_07_p2p_transport_lifecycle.md)
- [Bounded Remote Activity ADR](../adr/2026_07_bounded_remote_activity.md)
@@ -0,0 +1,228 @@
---
date: 2026-09-04
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
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;
7. compose the late core serviceFeatures whose handlers must follow host features and add-ons; and
8. call `onBindFunction()` for each registered Module.
The Module constructor therefore runs before its handler bindings, while the complete Service Hub and ServiceModules already exist. `bindModuleFunctions()` then invokes every `onBindFunction()` and runs `__$checkInstanceBinding()`. That diagnostic compares underscore-prefixed prototype methods with method references found in the source text of `onBindFunction()`.
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.
### Ordered start-up composition and registration-only features
Configured Vault admission and the checks which follow database preparation are composed by `src/serviceFeatures/startupLifecycle/`. The directory keeps onboarding admission, compromised-chunk inspection, incomplete-document repair, Config Doctor, and the obsolete bulk-send setting migration as separate operations. One feature composer owns their order and receives the compatibility-review wait operation explicitly; an individual operation does not call the composer.
The layout-ready admission handler uses priority 1. This preserves ordinary priority-0 host integration before admission, while keeping an unconfigured Vault outside the flag-file recovery handlers at priorities 5, 10, and 20, and the compatibility review at priority 30. Admission belongs to one plug-in process: an initially unconfigured process remains inert until setup restarts it, and declining the requested restart does not trigger an in-process reconfiguration. Changing an admitted process back to unconfigured retires its Config Doctor and incomplete-document repair request handlers. The handlers also recheck the current configured state and database readiness when invoked, so a pending restart cannot expose partially initialised or retired state. The first-initialise handler rechecks admission before retaining the established order after the file watcher has been started: database readiness, compromised chunks, incomplete documents, compatibility review, Config Doctor, and the bulk-send setting migration.
Command and ribbon registration are serviceFeatures for the same dependency-visibility reason, but they are not start-up migrations. The basic commands remain a host-neutral feature composed by `LiveSyncBaseCore`, while the replication ribbon remains an Obsidian-only feature composed by the Obsidian host. Both retain `onInitialise` registration so moving them out of the Module list does not make their effects run during construction.
### Private state and ordered handlers: target filters
Commonlib's `targetFilter.ts` keeps each cache or readiness gate in the factory which owns one predicate. `useTargetFilters()` constructs those predicates and registers them in their required order.
The state remains private to the composed feature. It does not become a `LiveSyncBaseCore` property or a ServiceModule merely because it persists across calls.
### Implemented composition: conflict resolution
Conflict checking and resolution are composed for every host by `useConflictResolutionFeature`. The feature owns its `QueueProcessor` privately and registers the conflict Service handlers directly. Its operations receive explicit collaborators for settings, active-file state, database and storage access, replication, logging, and host events. No consumer locates a conflict Module or retains the queue.
The scheduling queue remains one state owner. It publishes `conflictProcessQueueCount`, coalesces pending checks for the same path, and makes `ensureAllProcessed()` wait for conflict resolution to finish. Repeated resolver invocations for one path retain only the newest waiting request and close an active comparison for that path before waiting for the per-file resolver, while comparisons for other paths remain open. Resolution remains host-neutral and communicates dialogue cancellation through `services.context.events`, so CLI, WebApp, and Obsidian compositions use their own selected event channel.
Interactive resolution is a separate Obsidian-owned serviceFeature. It registers the manual conflict handler, commands, start-up scan, unresolved-message contribution, cancellation listener, and unload clean-up. Its postponed-conflict set, active dialogue, and dialogue queue are private, session-local state. Manual comparisons are shown one at a time: a request for the active file publishes `EVENT_CONFLICT_CANCELLED` to cancel and replace its dialogue, while a request for another file waits. A resolution received through replication closes an open dialogue for the resolved path through the same event, or discards its waiting request before a stale dialogue can open. On unload, the feature drops waiting requests and publishes the same event for the active path before the host event channel is retired, so the dialogue closes and its waiting operation completes. The feature receives a dialogue-opening adapter and connects to the common feature only through the conflict Service; it does not expose an Obsidian application or dialogue as a general capability.
Both operation layers acquire the active local database through an operation-time accessor. Composition occurs before the database is opened, and a reset may replace the active instance, so retaining the database object at composition time would violate both start-up and reset boundaries.
`ConflictResolveModal` remains a focused class. One instance owns one dialogue's result promise, event subscription, and close lifetime, which is stable identity and resource ownership rather than application composition. This preserves the distinction between a useful object lifetime and a legacy Module used as a service locator.
## Interaction-based testing
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.
+384
View File
@@ -0,0 +1,384 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Project glossary
This glossary records stable, project-specific meanings used by Self-hosted
LiveSync. Ordinary English and established technology terms retain their usual
meanings unless they are defined here. Exact code identifiers, API names, and
user-interface labels retain their source spelling.
The sections describe the intended audience, not a visibility guarantee. A
term in the developer and design section can appear in code, tests, logs, or
diagnostics. That does not make it user-interface vocabulary or a public
extension contract.
## User-facing and operational terms
These terms can appear in the user interface, user documentation, setup and
recovery guidance, or diagnostics intended for users.
### AR
- **Boot-up sequence (boot sequence):** The initialisation process of the
plug-in when Obsidian starts. It begins with loading the plug-in, setting up
core services, loading saved settings, and opening the local database. After
the layout is ready, the plug-in checks for flag files, runs configuration
diagnostics, connects to the remote database, and begins file watching. The
sequence finishes when the plug-in is ready and operational.
- **Broken files (size mismatch):** A state where a file's Metadata and the
content stored in its Chunks do not match, causing file retrieval or
synchronisation failures. Inspect these mismatches with **Inspect conflicts
and file/database differences** in the Hatch pane, then handle one exact
revision at a time.
- **Chunk / Chunks:** Divided units of data stored in the database or Object
Storage to support efficient synchronisation.
- **Compaction:** A database maintenance procedure which discards old
historical document revisions to reduce remote database size.
- **Continuous replication:** A provider's long-running replication mode. It
remains active to exchange changes until stopped and is distinct from a
finite OneShot Sync operation.
- **Custom HTTP Handler / Use Internal API (CORS bypass settings):** Settings
which bypass CORS restrictions by routing requests through Obsidian's native
request APIs. There are separate settings for each central remote type:
- **S3-compatible Object Storage (`useCustomRequestHandler`):** Labelled
**Use Custom HTTP Handler** in the standard settings tab and **Use internal
API** in the Svelte Setup Wizard dialogue. It is represented as `useProxy`
in Setup URI query parameters for compatibility.
- **CouchDB (`useRequestAPI`):** Labelled **Use Request API to avoid
inevitable CORS problem** in the standard settings tab and **Use Internal
API** in the Svelte Setup Wizard dialogue. It is represented as
`useRequestAPI` in Setup URI query parameters.
- **Customisation Sync:** The feature which synchronises settings, snippets,
themes, and plug-ins. Write 'Customisation' with an 's' in documentation;
technical configuration and links can use `customization` where required.
- **Database Adapter (IDB and IndexedDB):** The local database storage
interface used by PouchDB. The `IDB` adapter is recommended because the older
`IndexedDB` adapter is obsolete and can cause memory leaks in LiveSync mode.
Switching adapters requires local data migration and an Obsidian restart,
but not a full database rebuild.
- **Database Suffix (`additionalSuffixOfDatabaseName`):** A suffix appended to
the database name so that multiple Vaults with the same name can synchronise
to the same remote server.
- **E2EE Algorithm:** The cryptographic algorithm version used for end-to-end
encryption. All synchronising devices must use a compatible version, such as
`V2` or `V1`.
- **Eden (Eden Chunks):** A sunset-compatibility optimisation in which newly
created Chunks are held inside the document until they stabilise, before
becoming independent Chunks.
- **Fast Setup (Simple Fetch):** The preferred automated initial
synchronisation flow for a secondary device. It uses Streaming replication
for the initial download and delays local file reflection to avoid temporary
synchronisation warnings.
- **Fast Fetch:** The CouchDB-specific Streaming replication path used by Fast
Setup. It reads the changes feed in bounded pages and persists a checkpoint
so an interrupted transfer can resume. An ineligible transport uses the
ordinary fetch path instead.
- **Flag files (`redflag.md`, `redflag2.md`, and `redflag3.md`):** Special
Markdown files or directories at the Vault root which stop the boot-up
sequence or trigger recovery work. `redflag.md` suspends all processes,
`redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild, and
`redflag3.md` (`flag_fetch.md`) discards and fetches the local database again.
- **Garbage Collection (GC):** The maintenance process which identifies Chunk
documents not reachable from a current file or conflict branch, records
logical deletions for them, propagates those deletions, and requests remote
compaction to reclaim storage.
- **Hatch (Hatch pane):** The troubleshooting and maintenance section in the
plug-in settings. It contains diagnostics, database reset controls, status
reports, and advanced edge-case settings.
- **Hidden File Sync:** The feature which synchronises files in hidden
directories, such as `.obsidian`.
- **JWT Authentication:** An experimental CouchDB authentication option which
uses a JSON Web Token instead of standard credentials. It requires a private
key or secret, algorithm, expiry duration, subject, and key ID.
- **LiveSync:** This name has two established meanings: the shortened plug-in
name for Self-hosted LiveSync, and the Sync Mode for continuous, real-time
synchronisation. Prefer 'Continuous replication' in design documentation
when the mode, rather than the product, is meant.
- **livesync-serverpeer / WebPeer:** Specialised clients which assist WebRTC
peer-to-peer communication.
- **Metadata (file metadata):** A database document which stores file
properties, including its name, path, size, modification time, and references
to the Chunks containing its content. PouchDB or CouchDB revision metadata
carries conflict state; the file Metadata document has no separate history
field. Metadata and file content are stored separately.
- **OneShot Sync (OneShot replication):** One finite bidirectional
synchronisation operation, normally pull then push, which is requested
directly or by an event. It is distinct from Continuous replication.
- **Overwrite Server Data with This Device's Files:** A maintenance operation,
formerly named `Rebuild everything`, which discards the remote database and
rebuilds the local and remote databases from the current files on one
authoritative device.
- **Path Obfuscation:** A privacy option which encrypts file paths and folder
names on the remote server.
- **plug-in:** The spelling used in user-facing messages and general prose.
Retain `plugin` in code, configuration, and established technical names.
- **Remediation (`maxMTimeForReflectEvents`):** A recovery setting which limits
reflection of changes from the database to the Vault by ignoring file events
after a specified date and time.
- **Reset Synchronisation on This Device:** A maintenance operation, formerly
named `Fetch everything`, which discards the local database and rebuilds it
from the remote database.
### Revision
A revision is a version of one PouchDB or CouchDB document. Concurrent changes
can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one can apply to
the same revision:
- **leaf:** Has no known child revision.
- **winner:** Is the leaf selected by PouchDB or CouchDB as the current
document.
- **conflict:** Is another current leaf which was not selected as the winner.
- **Vault-matching:** Represents the same file content, or the same absent-file
state, as the current Vault. More than one revision can match.
- **displayed:** Is recorded by valid device-local file provenance as the
branch represented in the Vault. A pending local edit might no longer match
its bytes, but still extends this recorded branch.
- **logically deleted:** Represents absence of the file through a deletion
marker. A logically deleted revision can also be a leaf, winner, conflict,
or Vault-matching revision. An absent file retains no displayed provenance.
Avoid 'live revision' because it can mean either a current leaf or a
non-deleted revision. See
[Independent revision properties](specs_conflict_resolution.md#independent-revision-properties)
for the relationship between revision-tree roles, Vault state, and
device-local provenance.
### SZ
- **Scram (Scram Switches):** Emergency controls which suspend file watching or
database reflection to reduce the risk of corruption or unintended changes.
- **Security Seed:** The remote PBKDF2 salt used to derive the encryption key
for replication. It must be read from, or established on, the remote before
encrypted synchronisation.
- **Segmenter (Segmented-splitter):** A chunking method which divides files at
semantic boundaries, such as paragraphs or sections, rather than arbitrary
byte boundaries.
- **Self-hosted LiveSync:** The name of this plug-in. 'Self-hosted' is one
hyphenated word.
- **Setting Doctor (Config Doctor):** A diagnostic utility which identifies
configuration mismatches or suboptimal settings and presents recommended
values and reasons.
- **Setup URI:** An encrypted representation of plug-in settings and remote
configuration which can be transferred to another device and opened with a
passphrase.
- **Signalling relay (P2P):** A Nostr-compatible WebSocket relay used for peer
discovery and WebRTC connection negotiation. It does not store or transfer
Vault content. The project author operates a public relay as a best-effort
convenience, and users can supply another compatible relay.
- **Streaming replication (stream-based replication):** A transfer method
which downloads database documents as a continuous stream of events. Fast
Setup uses it to retrieve remote Metadata efficiently.
- **Sync Mode:** The trigger mechanism for synchronisation. Current modes are
**LiveSync**, for continuous replication, **Periodic Sync**, for work at a
configured interval, and **On Events**, for configured application events.
- **Synchronising devices:** Devices which participate in the same
synchronisation for a Vault. The term describes membership rather than
current activity, so it includes offline and idle devices.
- **TURN Server (WebRTC P2P):** A Traversal Using Relays around NAT server used
as an optional fallback when NAT or firewall rules prevent a direct WebRTC
connection. It relays encrypted WebRTC traffic and is distinct from the
signalling relay.
- **Update Thinning (Batch database update):** An optimisation which groups
local file edits over a short delay before committing them to the local
database, reducing database writes.
- **WebRTC P2P (peer-to-peer):** A synchronisation method which allows devices
to communicate directly without a central remote database.
## Developer and design terms
These definitions are stable vocabulary for architecture documents, ADRs,
implementation, tests, and code review. They might never appear in the user
interface. Inclusion here fixes their project meaning; it does not make the
named surface a public API or extension point.
### Active publication
The atomic publication of one Replicator provider, its `ReplicatorInstance`,
and its configuration identity, owned by Commonlib's `ReplicatorService`. Its
object identity is the admission fence for operations. An active publication
is also called the active Replicator publication, and its instance is the
**active Replicator**. 'Active publication' is more precise than 'current
Replicator' when admission or retirement matters.
### Adjunct P2P transport
P2P operating as an additional transport while CouchDB or Object Storage is
the selected main remote. It retains its own service and room-session
ownership; it is not the active Replicator for the main remote. Architecture
documents can shorten this to 'adjunct P2P' where the distinction is already
clear.
### Admission and reservation
**Admission** is permission for an operation to use one exact active
publication or P2P room session. A **reservation** records admitted work and
keeps its owner alive until that work settles. Retirement closes admission
before it waits for existing reservations, so later work cannot enter the
retiring generation.
### Bounded remote activity
A finite logical operation which can involve remote work, waiting, queueing, or
local result handling. Its lifetime is broader than an individual network
request. Continuous replication is not bounded remote activity. See the
[Bounded Remote Activity ADR](adr/2026_07_bounded_remote_activity.md).
### Capability
A typed declaration that a Replicator provider supports an operation or remote
resource, does not implement it, or considers it inapplicable. Capability
support is explicit; callers do not infer it from a legacy method, a Boolean
default, or a neutral return value.
### Central remote
A CouchDB or Object Storage remote which can require central preparation and
administration before replication. P2P is not a central remote. The **main
remote** is the `RemoteType` selected for the active Replicator; P2P can also
operate as an additional transport when a central remote is selected.
### Configuration identity
An opaque projection of the effective settings which determine whether an
existing provider instance or P2P binding can be retained. It can contain
credentials. Code can compare an identity for equality, but must not inspect,
log, persist, or display it.
### Fence, generation, and epoch
A **fence** prevents stale work or work admitted by one owner from affecting a
replacement owner or state. A **generation** normally changes when one local
lifecycle is invalidated. An **epoch** identifies one session or data history
where the owning contract uses that term. These values belong to distinct state
machines and are not interchangeable or evidence that another owner is
current.
### Focused view
A narrow interface exposing only the operations required by a consumer. It
delegates to a stable owner and does not independently own the underlying
mutable state or resource.
### Interaction authority
The explicit upper bound on user interaction permitted during an operation.
User-initiated work can receive selected permissions; unattended work carries
`NO_INTERACTION` and cannot open a dialogue, request peer selection, or obtain
authority through a fallback path.
### Journal remote epoch
The `protocolVersion:pbkdf2salt` value stored as
`CheckPointInfo.journalEpoch`. It identifies Journal checkpoint and
deduplication-cache history. It is data-history state, not a cancellation,
Replicator retirement, or operation-admission fence.
### Non-owning adapter
An adapter which implements a contract by delegating to another component
without owning the delegated resource. Closing it releases only resources
which the adapter itself owns. In particular, closing the active P2P adapter
does not close the stable P2P service or its room session.
### Owner and ownership
The **owner** is the single component responsible for creating, replacing,
stopping, and disposing a resource or stateful lifecycle. A borrower, adapter,
or focused view can use that resource only within its declared boundary and
must not perform the owner's lifecycle operations.
### P2P service, room session, and demand
The **P2P service** is the stable Commonlib owner which supplies focused views
and owns replaceable room sessions. A **P2P room session** is one active room
membership and the resources whose validity depends on it. **Demand** is one
persistent or finite reason for the owner to retain a room. Releasing one
demand does not close a room retained by another. A room's effective binding
includes the settings, local database object, and device identity which make
that session valid. A **session epoch** is the internal identity and fence of
one room-session object, not a persisted room name or a public numeric counter.
An **automation baseline** records peers for which the initial transfer
completed in the current logical automation lifecycle; it is owned
independently of a replaceable room session. A **configured target** is a
persisted peer name selected for unattended `P2P_SyncOnReplication`; the
request can wait for its advertisement, but cannot prompt for peer selection.
### Publication retirement
The lifecycle transition which removes an active publication from current
admission, asks its provider to stop transfer work, drains reservations for
that exact publication, closes the old instance, and marks retirement
complete. **Quiescing** is the state after admission has closed and before
retirement completes. A replacement cannot be published across an incomplete
retirement fence. A **candidate** is a newly constructed instance which remains
private until initialisation and freshness checks permit atomic publication.
### Remote resource and probe
A **remote resource** is a provider-declared, caller-owned object created from
one effective-settings snapshot for a bounded task. A **probe** is a bounded,
flow-specific validation or observation for connection, compatibility, setup,
or diagnostics. It can use an owned remote resource or an owner-arbitrated P2P
trial. It does not publish or replace the active Replicator, and the caller
disposes every resource which it owns.
### Replicator
The project abstraction which performs replication for one configured remote
kind and implements the `ReplicatorInstance` lifecycle contract. Use
'replication' for the process and 'Replicator' for this runtime abstraction. A
Replicator can be an owning transport implementation or a non-owning adapter;
the provider contract determines the boundary.
### Replicator provider definition
The exhaustive, host-composed declaration for one `RemoteType`: its
configuration identity, Replicator factory, readiness requirement,
capabilities, remote-resource factories, operation runners, and optional
central administration. The readiness requirement declares which layer must
establish operation preconditions. The provider catalogue is **closed
composition**, not a runtime registry: adding a provider requires changing,
shipping, and testing the owning composition. A **built-in provider** is one
included in that shipped catalogue. Architecture prose can shorten 'Replicator
provider definition' to 'provider'; it does not mean only the transport
instance.
### Replication outcome
The typed settlement of an attempted replication operation, represented by
`ReplicationOutcome`. Completed, partial, blocked, cancelled, and failed states
remain explicit; `undefined`, an empty value, or a compatibility default is not
treated as successful work.
### Service composition terms
- A **Service Hub** is the long-lived registry of service contracts for one
application composition.
- A **Service** owns a stable shared capability and its lifecycle.
- A **ServiceModule** is a host-created, long-lived stateful or resource-owning
capability shared through the typed `ServiceModules` record.
- A **serviceFeature** is a typed composition function which accepts declared
Services and ServiceModules, registers host integration, and can return a
focused view. It is not a runtime registry entry.
- A **legacy Module** is an existing application structure retained for
compatibility. New behaviour does not acquire the complete core merely to
imitate that locator pattern.
See [Service feature and legacy Module boundaries](design_docs/service_feature_and_legacy_module_boundaries.md)
for the selection and composition rules.
### Suspension
A reversible lifecycle action which stops active transfer work without
retiring the active Replicator publication. The P2P service also closes its
current room session during application suspension because that session has a
separate owner and lifecycle. Resumption can retain the Replicator instance and
open a new P2P room as required.
+58
View File
@@ -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
+63 -17
View File
@@ -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
@@ -1075,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)
@@ -1085,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
+1 -1
View File
@@ -20,7 +20,7 @@ Resolving a conflict writes the selected or merged result on one observed branch
### Independent revision properties
The modifiers defined under [Revision](terms.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
The modifiers defined under [Revision](glossary.md#revision) describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.
| Situation | Winner | Vault-matching | Displayed |
| ---------------------------------------------------- | ------------------ | --------------------------------------------------- | ---------------------------------------------------- |
+19 -1
View File
@@ -11,6 +11,24 @@
Note: The figure is drawn as single-directional, between two devices for demonstration purposes. Everything actually occurs bi-directionally between many devices at the same time.
## Current technical references
- [Database Data Structures](datastructure.md) describes current Metadata and
Chunk shapes, identifier handling, deletion, and raw remote representations.
- [Replicator architecture](design_docs/replicator_architecture.md) describes
provider composition, active Replicator publication, retirement, and P2P
ownership.
- [Conflict resolution and revision provenance](specs_conflict_resolution.md)
defines the current revision-tree and file-provenance rules.
- [Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md)
defines missing-Chunk arrival and quiescence handling.
- [Path component length compatibility](design_docs/path_component_length_compatibility.md)
explains why 255 UTF-8 bytes is an Android and Linux compatibility warning,
rather than a universal rule for deciding whether a path is valid.
- [Data Compression](specs_data_compression.md) and [Garbage Collection
V3](specs_garbage_collection.md) describe their respective storage and
maintenance contracts.
## Techniques to keep bandwidth consumption low.
![dedupe](../images/2.png)
![dedupe](../images/2.png)
+9 -94
View File
@@ -1,3 +1,10 @@
---
date: 2026-09-03
commonlib-version: "0.1.21"
self-hosted-livesync-version: "1.0.24"
status: accepted
---
# Notes on Terminology, Spelling, Vocabulary Conventions
## Spelling and Vocabulary conventions
@@ -24,97 +31,5 @@ All guidelines and conventions listed below are disclosed and maintained solely
### Terminology
- Boot-up sequence (boot-sequence)
- The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational.
- Broken files (Size mismatch)
- A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time.
- Chunk / Chunks
- Divided units of data stored in the database or object storage to facilitate efficient synchronisation.
- Compaction
- A database maintenance procedure that discards old historical document revisions to shrink the remote database size.
- Custom HTTP Handler / Use Internal API (CORS Bypass Settings)
- Settings used to bypass CORS restrictions by routing requests through Obsidian's native request APIs. There are two distinct settings under the hood depending on the remote server type:
- **For S3-compatible Object Storage (useCustomRequestHandler)**: Labeled as **"Use Custom HTTP Handler"** in the standard settings tab, **"Use internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useProxy` in the Setup URI's query parameters due to an unfortunate misunderstanding during development.
- **For CouchDB (useRequestAPI)**: Labeled as **"Use Request API to avoid `inevitable` CORS problem"** in the standard settings tab, **"Use Internal API"** in the Svelte-based Setup Wizard dialogue, and represented as `useRequestAPI` in the Setup URI's query parameters.
- Customisation Sync
- The feature that synchronises settings, snippets, themes, and plug-ins. Write with an "s" in documentation (`Customisation`), though technical configurations and links may use `customization`.
- Database Adapter (IDB vs. IndexedDB)
- The local database storage interface used by PouchDB. The `IDB` adapter is recommended since the older `IndexedDB` adapter is obsolete and known to cause memory leaks in `LiveSync` mode. Users can switch between these adapters without a full database rebuild, although a local data migration and an Obsidian restart are required.
- Database Suffix (additionalSuffixOfDatabaseName)
- A unique suffix appended to the database name to allow synchronising multiple vaults with the same name on the same remote server.
- E2EE Algorithm
- The cryptographic algorithm version used for end-to-end encryption. All synchronising devices must be configured with a compatible version (such as `V2` or `V1`).
- Eden (Eden Chunks)
- A performance optimisation where newly created chunks are held within the document until they stabilise, before graduating to independent chunks.
- Fast Setup (Simple Fetch)
- A simplified, automated initial synchronisation flow triggered when setting up subsequent devices or recovering a database. It bypasses the detailed step-by-step setup wizard dialogues, prompting the user with high-level data processing decisions and completing the initial download and local file scan in one continuous process.
- Flag files (redflag.md, redflag2.md, redflag3.md)
- Special Markdown files (or directories) placed at the root of the vault to stop the boot-up sequence or trigger recovery tasks. For instance, `redflag.md` suspends all processes, while `redflag2.md` (`flag_rebuild.md`) triggers a full database rebuild and `redflag3.md` (`flag_fetch.md`) discards the local database to fetch it again from the remote.
- Garbage Collection (GC)
- The process of identifying and purging unreferenced chunks (unused data) from local and remote databases to reclaim storage space.
- Hatch (Hatch pane)
- A dedicated troubleshooting and maintenance section in the plug-in settings, typically hidden behind a warning-labeled collapsible panel to prevent accidental misconfiguration. It contains diagnostic utilities, database reset controls, status reports, and advanced edge-case patches.
- Hidden File Sync
- The feature that synchronises files located in hidden directories (like `.obsidian`).
- JWT Authentication
- An experimental authentication option for CouchDB allowing secure token-based authentication instead of standard credentials. It requires a configured private key/secret, algorithm, expiration duration, subject, and key ID.
- LiveSync
- A very confusing term.
- As a shortened form of `Self-hosted LiveSync`.
- As the name of a synchronisation mode. This should be changed to `Continuous`, in contrast to `Periodic`.
- livesync-serverpeer / webpeer
- Pseudo-clients that assist in WebRTC peer-to-peer communication.
- Metadata (File metadata)
- A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning.
- OneShot Sync
- A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication.
- Overwrite Server Data with This Device's Files
- A maintenance operation (formerly known as `Rebuild everything`) that discards the remote database and reconstructs it by uploading all current local files as a fresh database, overwriting any remote changes.
- Path Obfuscation
- A privacy option that encrypts file paths and folder names on the remote server.
- plug-in
- We use the hyphenated form `plug-in` in user-facing messages and general documentation, while `plugin` may appear in codebase files, configuration settings, or technical contexts.
- Signalling relay (P2P)
- A Nostr-compatible WebSocket relay used for peer discovery and WebRTC connection negotiation. It does not store or transfer Vault contents. The project author operates a public relay as a best-effort convenience, and users can provide another compatible relay.
- Remediation (maxMTimeForReflectEvents)
- A recovery setting that restricts the propagation of changes from the database to local storage, ignoring any file events (such as accidental mass deletions) that occurred after a specified date and time.
- Reset Synchronisation on This Device
- A maintenance operation (formerly known as `Fetch everything`) that discards the local database and reconstructs it by downloading all data from the remote server.
#### Revision
A revision is a version of one PouchDB/CouchDB document. Concurrent changes can form a revision tree with more than one current branch.
Revision modifiers describe independent properties. More than one may apply to the same revision:
- **leaf**: Has no known child revision.
- **winner**: Is the leaf selected by PouchDB/CouchDB as the current document.
- **conflict**: Is another current leaf which was not selected as the winner.
- **Vault-matching**: Represents the same file contents, or the same absent-file state, as the current Vault. More than one revision may match.
- **displayed**: Is recorded by valid device-local file provenance as the branch represented in the Vault. A pending local edit may no longer match its bytes, but still extends this recorded branch.
- **logically deleted**: Represents the absence of the file through a deletion marker. A logically deleted revision may also be a leaf, winner, conflict, or Vault-matching revision. An absent file retains no displayed provenance.
Avoid **live revision** in prose because it can ambiguously mean either a current leaf or a non-deleted revision. See [Independent revision properties](specs_conflict_resolution.md#independent-revision-properties) for the relationship between revision-tree roles, Vault state, and device-local provenance.
- Scram (Scram Switches)
- Emergency controls in the settings that allow users to suspend file watching or database writes to prevent corruption.
- Segmenter (Segmented-splitter)
- A chunking method that divides files on semantic boundaries (such as paragraphs or sections) rather than arbitrary byte boundaries.
- Self-hosted LiveSync
- The name of this plug-in. `Self-hosted` is one word.
- Setting Doctor (Config Doctor)
- A diagnostic utility that checks for mismatches or suboptimal configurations, presenting users with ideal values and recommendation reasons to easily resolve issues during migration, configuration import, or general troubleshooting.
- Setup URI
- An encrypted representation of the plug-in's settings containing server configuration, which allows users to clone their configuration across devices securely using a passphrase.
- Streaming replication (Stream-based replication)
- A data transfer method that downloads database documents as a continuous stream of events. It is significantly faster than traditional chunk-by-chunk HTTP requests and is used during Fast Setup to retrieve remote metadata quickly.
- Sync Mode
- The replication trigger mechanism. Users can select from `On Events` (synchronising on local file changes), `Periodic and Events` (synchronising at fixed intervals as well as on events), or `LiveSync` (continuous, real-time synchronisation).
- Synchronising devices
- Devices which participate in the same synchronisation for a Vault. The term describes membership rather than current activity, so it includes offline and idle devices.
- TURN Server (WebRTC P2P)
- A Traversal Using Relays around NAT server used as an optional fallback to relay encrypted WebRTC traffic when strict NAT or firewall rules block a direct peer connection. It is distinct from the signalling relay.
- Update Thinning (Batch database update)
- An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations.
- WebRTC P2P (Peer-to-Peer)
- A synchronisation method enabling direct communication between devices without a central server database.
Project-specific meanings are defined separately in the
[Project glossary](glossary.md).
+1 -1
View File
@@ -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`.
![Advanced features enabled](../../images/hidden-file-sync/guide-hidden-file-advanced-features.png)
+10
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.17",
"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",
+210 -3196
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.17",
"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.18",
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
"@vrtmrz/livesync-commonlib": "0.1.22",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
@@ -187,7 +186,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.53",
"octagonal-wheels": "^0.1.54",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
+44 -30
View File
@@ -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,36 +15,36 @@ 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";
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ModuleBasicMenu } from "./modules/essential/ModuleBasicMenu";
import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/prepareDatabaseForUse";
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
import { useReplicationFeature } from "./serviceFeatures/replication";
import { useConflictResolutionFeature } from "./serviceFeatures/conflictResolution";
import { useBasicCommandsFeature } from "./serviceFeatures/basicCommands";
/** Focused views returned by serviceFeatures which the host may consume during composition. */
export interface LiveSyncCoreFeatureViews {
readonly replicationScheduling: ReplicationSchedulingControl;
}
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
export class LiveSyncBaseCore<
T extends ServiceContext = ServiceContext,
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
>
implements
LiveSyncLocalDBEnv,
LiveSyncReplicatorEnv,
LiveSyncJournalReplicatorEnv,
LiveSyncCouchDBReplicatorEnv,
HasSettings<ObsidianLiveSyncSettings>
implements LiveSyncLocalDBEnv, LiveSyncCouchDBReplicatorEnv, HasSettings<ObsidianLiveSyncSettings>
{
addOns = [] as TCommands[];
@@ -74,18 +78,23 @@ 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);
}
// Compose late core features after host features and add-ons, then bind
// legacy modules so lifecycle handlers observe the required order.
useReplicationFeature(this);
useBasicCommandsFeature(this);
this.bindModuleFunctions();
}
/**
@@ -136,16 +145,16 @@ 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));
for (const module of extraModules) {
this._registerModule(module);
@@ -223,10 +232,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 +283,16 @@ export class LiveSyncBaseCore<
* Initialise ServiceFeatures.
* (Please refer `serviceFeatures` for more details)
*/
initialiseServiceFeatures() {
initialiseServiceFeatures(): LiveSyncCoreFeatureViews {
useConflictResolutionFeature(this);
useTargetFilters(this);
// enable target filter feature.
usePrepareDatabaseForUse(this);
// Migration to multiple remote configurations
useRemoteConfigurationMigration(this);
return Object.freeze({
replicationScheduling: useReplicationScheduling(this),
});
}
}
+5 -3
View File
@@ -82,9 +82,11 @@ RUN apt-get update \
WORKDIR /deps
# package.json lists only the packages that the CLI requires
COPY src/apps/cli/package.json ./package.json
RUN npm install --omit=dev
# Remove build-only dependencies before resolving the standalone runtime tree.
# npm --omit=dev omits them from disk, but still resolves their peer graph.
COPY src/apps/cli/package.json ./package.json
RUN npm pkg delete devDependencies \
&& npm install --omit=dev
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — runtime
+51 -32
View File
@@ -48,7 +48,7 @@ CLI Main
- Settings management (JSON file)
- Graceful shutdown handling
## Usage
## Command overview
The CLI operates on a **database directory** which contains PouchDB data and settings.
@@ -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 27 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);
+55 -65
View File
@@ -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;
}
+131 -2
View File
@@ -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",
},
});
});
});
+73 -172
View File
@@ -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";
@@ -19,75 +15,34 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
import type { CLICommandContext, CLIOptions } from "./types";
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import {
performFullScan,
VaultScanResults,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
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 +50,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 +93,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 +118,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 +153,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 +175,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 +196,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
);
}
}
return !!result;
return isReplicationCompleted(result);
}
if (options.command === "p2p-peers") {
@@ -227,7 +205,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 +222,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;
@@ -554,7 +532,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
writeStderrLine(standardIo, "[Command] mirror");
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
return await performFullScan(core, log, errorManager, false, true);
return (await performFullScan(core, log, errorManager, false, true)) === VaultScanResults.COMPLETED;
}
if (options.command === "remote-add") {
@@ -757,88 +735,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 +761,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;
+249 -20
View File
@@ -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 () => {
+6 -1
View File
@@ -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;
+57 -8
View File
@@ -8,7 +8,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)"
CLI_DIR="$REPO_ROOT/src/apps/cli"
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
@@ -104,30 +104,70 @@ fi
# ── Install binary ───────────────────────────────────────────────────────────
if [[ "$INSTALL_MODE" == "user" ]]; then
BIN_DIR="$HOME/.local/bin"
LIB_DIR="$HOME/.local/lib/livesync-cli"
UNIT_DIR="$HOME/.config/systemd/user"
SYSTEMCTL_FLAGS="--user"
else
BIN_DIR="/usr/local/bin"
LIB_DIR="/usr/local/lib/livesync-cli"
UNIT_DIR="/etc/systemd/system"
SYSTEMCTL_FLAGS=""
fi
mkdir -p "$BIN_DIR"
LIB_PARENT="$(dirname -- "$LIB_DIR")"
mkdir -p "$BIN_DIR" "$LIB_PARENT"
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
LIVESYNC_JS="$LIB_DIR/dist/index.cjs"
# Copy the CJS bundle so the wrapper is self-contained and independent of the
# build directory location.
cp "$BUILT_CJS" "$LIVESYNC_JS"
# Build a complete runtime payload before replacing any previous installation.
# The Vite output contains hashed sibling chunks, while some Node dependencies
# deliberately remain external and must be installed next to the bundle.
PAYLOAD_STAGING="$(mktemp -d "$LIB_PARENT/.livesync-cli.install.XXXXXX")"
cleanup_payload() {
if [[ -n "$PAYLOAD_STAGING" ]] && [[ -e "$PAYLOAD_STAGING" ]]; then
rm -rf -- "$PAYLOAD_STAGING"
fi
}
trap cleanup_payload EXIT
# Write a bash wrapper that invokes node on the installed bundle.
cp "$CLI_DIR/package.json" "$PAYLOAD_STAGING/package.json"
npm install --omit=dev --no-audit --no-fund --prefix "$PAYLOAD_STAGING"
cp -R "$CLI_DIR/dist" "$PAYLOAD_STAGING/dist"
if ! node "$PAYLOAD_STAGING/dist/index.cjs" --help >/dev/null; then
echo "Error: installed CLI failed its start-up check" >&2
exit 1
fi
PAYLOAD_BACKUP=""
if [[ -e "$LIB_DIR" ]] || [[ -L "$LIB_DIR" ]]; then
PAYLOAD_BACKUP="$(mktemp -d "$LIB_PARENT/.livesync-cli.backup.XXXXXX")"
rmdir "$PAYLOAD_BACKUP"
mv -- "$LIB_DIR" "$PAYLOAD_BACKUP"
fi
if ! mv -- "$PAYLOAD_STAGING" "$LIB_DIR"; then
if [[ -n "$PAYLOAD_BACKUP" ]]; then
mv -- "$PAYLOAD_BACKUP" "$LIB_DIR"
fi
echo "Error: failed to install the CLI files at $LIB_DIR" >&2
exit 1
fi
PAYLOAD_STAGING=""
if [[ -n "$PAYLOAD_BACKUP" ]]; then
rm -rf -- "$PAYLOAD_BACKUP"
fi
trap - EXIT
# Write a bash wrapper that invokes Node.js on the installed payload.
cat > "$LIVESYNC_BIN" <<WRAPPER
#!/usr/bin/env bash
exec node "$LIVESYNC_JS" "\$@"
WRAPPER
chmod +x "$LIVESYNC_BIN"
echo "[INFO] Installed bundle: $LIVESYNC_JS"
echo "[INFO] Installed CLI files: $LIB_DIR"
echo "[INFO] Installed binary: $LIVESYNC_BIN"
# ── Write systemd unit ───────────────────────────────────────────────────────
@@ -180,6 +220,15 @@ systemctl $SYSTEMCTL_FLAGS daemon-reload
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
sleep 1
# shellcheck disable=SC2086
if ! systemctl $SYSTEMCTL_FLAGS is-active --quiet livesync-cli; then
echo "Error: livesync-cli service did not remain active after startup." >&2
# shellcheck disable=SC2086
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
exit 1
fi
echo ""
echo "[Done] livesync-cli service installed and started."
echo ""
+192
View File
@@ -0,0 +1,192 @@
import { spawnSync } from "node:child_process";
import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const deploySourceDirectory = dirname(fileURLToPath(import.meta.url));
const temporaryDirectories: string[] = [];
type InstallerFixture = {
cliDirectory: string;
environment: NodeJS.ProcessEnv;
homeDirectory: string;
installerPath: string;
npmCallLog: string;
repositoryRoot: string;
systemctlCallLog: string;
vaultDirectory: string;
};
async function writeExecutable(path: string, content: string): Promise<void> {
await writeFile(path, `${content}\n`, "utf8");
await chmod(path, 0o755);
}
async function createInstallerFixture(serviceActive: boolean): Promise<InstallerFixture> {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "livesync-cli-installer-"));
temporaryDirectories.push(temporaryDirectory);
const repositoryRoot = join(temporaryDirectory, "repository");
const cliDirectory = join(repositoryRoot, "src", "apps", "cli");
const deployDirectory = join(cliDirectory, "deploy");
const distDirectory = join(cliDirectory, "dist");
const fakeBinDirectory = join(temporaryDirectory, "fake-bin");
const homeDirectory = join(temporaryDirectory, "home");
const vaultDirectory = join(temporaryDirectory, "vault");
const npmCallLog = join(temporaryDirectory, "npm-calls.log");
const systemctlCallLog = join(temporaryDirectory, "systemctl-calls.log");
await Promise.all([
mkdir(deployDirectory, { recursive: true }),
mkdir(distDirectory, { recursive: true }),
mkdir(fakeBinDirectory, { recursive: true }),
mkdir(homeDirectory, { recursive: true }),
mkdir(vaultDirectory, { recursive: true }),
]);
await Promise.all([
copyFile(join(deploySourceDirectory, "install.sh"), join(deployDirectory, "install.sh")),
copyFile(join(deploySourceDirectory, "livesync-cli.service"), join(deployDirectory, "livesync-cli.service")),
writeFile(
join(repositoryRoot, "package.json"),
JSON.stringify({ private: true, workspaces: ["src/apps/*"] }),
"utf8"
),
writeFile(
join(cliDirectory, "package.json"),
JSON.stringify({
name: "self-hosted-livesync-cli",
private: true,
version: "0.0.0",
dependencies: { "fixture-runtime-dependency": "1.0.0" },
}),
"utf8"
),
writeFile(
join(distDirectory, "index.cjs"),
'const chunk = require("./chunk.cjs");\n' +
'const dependency = require("fixture-runtime-dependency");\n' +
"process.stdout.write(`${chunk}:${dependency}\\n`);\n",
"utf8"
),
writeFile(join(distDirectory, "chunk.cjs"), 'module.exports = "chunk-ready";\n', "utf8"),
]);
await writeExecutable(
join(fakeBinDirectory, "npm"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s|%s\\n\' "$PWD" "$*" >> "$NPM_CALL_LOG"',
'prefix=""',
"expect_prefix=0",
'for argument in "$@"; do',
' if [[ "$expect_prefix" -eq 1 ]]; then',
' prefix="$argument"',
" expect_prefix=0",
' elif [[ "$argument" == "--prefix" ]]; then',
" expect_prefix=1",
" fi",
"done",
'if [[ -n "$prefix" ]]; then',
' mkdir -p "$prefix/node_modules/fixture-runtime-dependency"',
" printf '%s\\n' 'module.exports = \"dependency-ready\";' > \"$prefix/node_modules/fixture-runtime-dependency/index.js\"",
"fi",
].join("\n")
);
await writeExecutable(
join(fakeBinDirectory, "systemctl"),
[
"#!/usr/bin/env bash",
"set -euo pipefail",
'printf \'%s\\n\' "$*" >> "$SYSTEMCTL_CALL_LOG"',
'if [[ " $* " == *" is-active "* ]]; then',
' [[ "${FAKE_SYSTEMCTL_ACTIVE:-1}" == "1" ]]',
" exit",
"fi",
'if [[ " $* " == *" status "* ]]; then',
" printf '%s\\n' \"fixture service status\"",
"fi",
].join("\n")
);
await writeExecutable(join(fakeBinDirectory, "sleep"), ["#!/usr/bin/env bash", "exit 0"].join("\n"));
return {
cliDirectory,
environment: {
...process.env,
FAKE_SYSTEMCTL_ACTIVE: serviceActive ? "1" : "0",
HOME: homeDirectory,
NPM_CALL_LOG: npmCallLog,
PATH: `${fakeBinDirectory}${delimiter}${process.env.PATH ?? ""}`,
SYSTEMCTL_CALL_LOG: systemctlCallLog,
},
homeDirectory,
installerPath: join(deployDirectory, "install.sh"),
npmCallLog,
repositoryRoot,
systemctlCallLog,
vaultDirectory,
};
}
function runInstaller(fixture: InstallerFixture) {
return spawnSync("bash", [fixture.installerPath, "--vault", fixture.vaultDirectory], {
encoding: "utf8",
env: fixture.environment,
});
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))
);
});
describe.skipIf(process.platform === "win32")("CLI systemd installer", () => {
it("installs a runnable CLI independently of the source repository", async () => {
const fixture = await createInstallerFixture(true);
const installation = runInstaller(fixture);
expect(installation.error).toBeUndefined();
expect(installation.status, installation.stderr).toBe(0);
expect(installation.stdout).toContain("[Done] livesync-cli service installed and started.");
const installedCommand = join(fixture.homeDirectory, ".local", "bin", "livesync-cli");
const installedPayload = join(fixture.homeDirectory, ".local", "lib", "livesync-cli", "dist", "index.cjs");
const installedUnit = join(fixture.homeDirectory, ".config", "systemd", "user", "livesync-cli.service");
expect(await readFile(installedPayload, "utf8")).toContain('require("./chunk.cjs")');
expect(await readFile(installedUnit, "utf8")).toContain("Type=exec");
await rm(fixture.repositoryRoot, { recursive: true });
const command = spawnSync(installedCommand, [], { encoding: "utf8", env: fixture.environment });
expect(command.error).toBeUndefined();
expect(command.status, command.stderr).toBe(0);
expect(command.stdout).toBe("chunk-ready:dependency-ready\n");
const npmCalls = await readFile(fixture.npmCallLog, "utf8");
expect(npmCalls).toContain(`${fixture.repositoryRoot}|install --silent`);
expect(npmCalls).toContain(`${fixture.cliDirectory}|run build`);
expect(npmCalls).toMatch(/install .*--omit=dev|install --omit=dev/);
const systemctlCalls = await readFile(fixture.systemctlCallLog, "utf8");
expect(systemctlCalls).toContain("--user enable --now livesync-cli");
expect(systemctlCalls).toContain("--user is-active --quiet livesync-cli");
});
it("does not report success when the service fails to remain active", async () => {
const fixture = await createInstallerFixture(false);
const installation = runInstaller(fixture);
const combinedOutput = `${installation.stdout}\n${installation.stderr}`;
expect(installation.error).toBeUndefined();
expect(installation.status).not.toBe(0);
expect(combinedOutput).toContain("service did not remain active after startup");
expect(combinedOutput).not.toContain("[Done]");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Type=exec
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
Restart=on-failure
RestartSec=10
+24 -5
View File
@@ -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,
+10
View File
@@ -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([]);
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.17-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" &&
@@ -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;
}
+1
View File
@@ -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
View File
@@ -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:");
});
+18 -1
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.17-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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.17-webpeer",
"version": "1.0.24-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -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;
+5 -12
View File
@@ -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()
+283
View File
@@ -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();
});
});
+32
View File
@@ -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,
},
-2
View File
@@ -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.",
@@ -59,10 +57,6 @@ export const liveSyncProvisionalEnglishMessages = {
"Follow whenever this device connects": "Follow whenever this device connects",
"Include in the P2P synchronisation command": "Include in the P2P synchronisation command",
"More actions for ${DEVICE}": "More actions for ${DEVICE}",
"Create or connect to database and continue": "Create or connect to database and continue",
"Connect to existing database and continue": "Connect to existing database and continue",
"Test connection and save": "Test connection and save",
"Save without connecting": "Save without connecting",
"Use this device's settings": "Use this device's settings",
Retry: "Retry",
"No Synchronisation Settings Found": "No Synchronisation Settings Found",
@@ -75,14 +69,6 @@ export const liveSyncProvisionalEnglishMessages = {
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
"Skips checking and applying synchronisation settings from the remote.":
"Skips checking and applying synchronisation settings from the remote.",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"CouchDB validates the database name when you connect. The name must not be empty.":
"CouchDB validates the database name when you connect. The name must not be empty.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"Check server requirements": "Check server requirements",
"Change CouchDB server setting": "Change CouchDB server setting",
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?":
"Change CouchDB server setting '${SETTING}' to '${VALUE}'?",
+176 -195
View File
@@ -148,16 +148,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "(正则表达式)如果已设置,则所有匹配此模式的本地和远端文件变更都会被跳过。",
"zh-tw": "(正則表示式)若已設定,所有符合此模式的本機與遠端檔案變更都會被略過。",
},
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.":
{
def: "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
es: "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
ja: "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
ko: "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
ru: "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
zh: "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"zh-tw":
"(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
def: "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.",
},
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
{
@@ -740,6 +733,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "检查尚未转换为路径混淆 ID 的文档,并在需要时将其转换。",
"zh-tw": "檢查尚未轉換為路徑混淆 ID 的文件,並在需要時進行轉換。",
},
"Check server requirements": {
def: "Check server requirements",
es: "Comprobar los requisitos del servidor",
},
"Checking connection... Please wait.": {
def: "Checking connection... Please wait.",
es: "Comprobando la conexión... Espera un momento.",
@@ -993,6 +990,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "연결",
"zh-tw": "連線",
},
"Connect to existing database and continue": {
def: "Connect to existing database and continue",
es: "Conectar a la base de datos existente y continuar",
},
"Connected to Signaling Server (as Peer ID: ${peerId})": {
def: "Connected to Signaling Server (as Peer ID: ${peerId})",
es: "Conectado al servidor de señalización (como ID de par: ${peerId})",
@@ -1094,6 +1095,14 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "CouchDB 连接调优",
"zh-tw": "CouchDB 連線調校",
},
"CouchDB validates the database name when you connect. The name must not be empty.": {
def: "CouchDB validates the database name when you connect. The name must not be empty.",
es: "CouchDB valida el nombre de la base de datos al conectar. El nombre no puede estar vacío.",
},
"Create or connect to database and continue": {
def: "Create or connect to database and continue",
es: "Crear o conectar a la base de datos y continuar",
},
"Create P2P remote": {
def: "Create P2P remote",
es: "Crear remoto P2P",
@@ -1433,7 +1442,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"dialog.yourLanguageAvailable": {
def: "Self-hosted LiveSync had translations for your language, so the %{Display language} setting was enabled.\n\nNote: Not all messages are translated. We are waiting for your contributions!\nNote 2: If you create an Issue, **please revert to Default** and then take screenshots, messages and logs. This can be done in the setting dialogue.\nMay you find it easy to use!",
es: "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a Predeterminado** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
es: "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste Idioma de visualización.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a Predeterminado** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
fr: "Self-hosted LiveSync dispose d'une traduction pour votre langue, le paramètre %{Display language} a donc été activé.\n\nNote : Tous les messages ne sont pas traduits. Nous attendons vos contributions !\nNote 2 : Si vous créez un ticket, **veuillez revenir à Par défaut** puis prendre des captures d'écran, messages et journaux. Cela peut être fait dans la boîte de dialogue des paramètres.\nBonne utilisation !",
he: "ל-Self-hosted LiveSync יש תרגום לשפתך, ולכן הגדרת %{Display language} הופעלה.\n\nהערה: לא כל ההודעות מתורגמות. אנחנו ממתינים לתרומותיך!\nהערה 2: אם אתה פותח Issue, **אנא חזור ל-%{lang-def}** ואז צלם צילומי מסך, הודעות ויומנים. ניתן לעשות זאת בדיאלוג ההגדרות.\nנקווה שתמצא/י את הפלאגין נוח לשימוש!",
ja: "Self-hosted LiveSync に設定されている言語の翻訳がありましたので、インターフェースの表示言語が適用されました。\n\n注意: 全てのメッセージは翻訳されていません。あなたの貢献をお待ちしています!\nGithubにIssueを作成する際には、 インターフェースの表示言語 を一旦 Default に戻してから、スクショやメッセージ、ログを収集してください。これは設定から変更できます。\n\n便利に使用できれば幸いです。",
@@ -2035,6 +2044,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "增大块大小",
"zh-tw": "擴大 chunk 大小",
},
"Enter a complete HTTP or HTTPS URL.": {
def: "Enter a complete HTTP or HTTPS URL.",
es: "Introduce una URL HTTP o HTTPS completa.",
},
"Enter a folder prefix (optional)": {
def: "Enter a folder prefix (optional)",
es: "Introduce un prefijo de carpeta (opcional)",
@@ -2530,6 +2543,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
ko: "해당 없는 항목 숨기기",
"zh-tw": "隱藏不適用的項目",
},
"Hide password": {
def: "Hide password",
es: "Ocultar contraseña",
},
"Higher (${local} > ${remote})": {
def: "Higher (${local} > ${remote})",
es: "Superior (${local} > ${remote})",
@@ -4195,6 +4212,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "等待就绪...",
"zh-tw": "正在等待就緒⋯",
},
"moduleLog.pathComponentTooLong": {
def: "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
},
"moduleLog.showLog": {
def: "Show Log",
es: "Mostrar registro",
@@ -4206,16 +4226,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 +4461,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 +4528,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 +4539,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 +4550,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 +4572,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 +4959,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 +5003,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 +5389,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": {
def: "Configured synchronization mode: LiveSync",
es: "Modo de sincronización configurado: Sincronización en Vivo",
es: "Modo de sincronización configurado: Sincronización en vivo",
fr: "Mode de synchronisation configuré : LiveSync",
he: "מצב סנכרון שהוגדר: LiveSync",
ja: "設定された同期モード: LiveSync",
@@ -5812,17 +5688,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 +5790,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 +6001,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.nameTestDatabaseConnection": {
def: "Test Database Connection",
es: "Probar Conexión de Base de Datos",
es: "Probar conexión de base de datos",
fr: "Tester la connexion à la base de données",
he: "בדוק חיבור למסד נתונים",
ja: "データベース接続テスト",
@@ -6158,7 +6012,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.nameValidateDatabaseConfig": {
def: "Validate Database Configuration",
es: "Validar Configuración de la Base de Datos",
es: "Validar configuración de la base de datos",
fr: "Valider la configuration de la base de données",
he: "אמת תצורת מסד נתונים",
ja: "データベース設定を検証",
@@ -6466,7 +6320,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.panelGeneralSettings": {
def: "General Settings",
es: "Configuraciones Generales",
es: "Configuraciones generales",
fr: "Paramètres généraux",
he: "הגדרות כלליות",
ja: "一般設定",
@@ -6477,7 +6331,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": {
def: "Privacy & Encryption",
es: "Privacidad y Cifrado",
es: "Privacidad y cifrado",
fr: "Confidentialité et chiffrement",
he: "פרטיות והצפנה",
ja: "プライバシーと暗号化",
@@ -6530,6 +6384,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 +6409,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 +6464,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 +6503,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 +6531,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 +6623,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 +6633,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 +6660,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
},
"obsidianLiveSyncSettingTab.titleSyncSettings": {
def: "Sync Settings",
es: "Configuraciones de Sincronización",
es: "Configuraciones de sincronización",
fr: "Paramètres de synchronisation",
he: "הגדרות סנכרון",
ja: "同期設定",
@@ -8187,7 +8051,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 +8404,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "将设置保存到一个 Markdown 文件中。当新设置到达时,您将收到通知。您可以根据平台设置不同的文件 ",
"zh-tw": "將設定儲存到 Markdown 檔案中。有新設定送達時會通知你,可依平台設定不同的檔案。",
},
"Save without connecting": {
def: "Save without connecting",
es: "Guardar sin conectar",
},
"Saving will be performed forcefully after this number of seconds.": {
def: "Saving will be performed forcefully after this number of seconds.",
es: "Guardado forzado tras esta cantidad de segundos",
@@ -8551,6 +8419,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "在此秒数后将强制执行保存 ",
"zh-tw": "經過這個秒數後,會強制執行儲存。",
},
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.":
{
def: "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
es: "Guardar sin una prueba de conexión correcta conserva este perfil, pero la sincronización automática puede fallar hasta que se corrija la conexión.",
},
"Scan a QR Code (Recommended for mobile)": {
def: "Scan a QR Code (Recommended for mobile)",
es: "Escanear un código QR (recomendado para móviles)",
@@ -9570,6 +9443,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "仅显示通知",
"zh-tw": "僅顯示通知",
},
"Show password": {
def: "Show password",
es: "Mostrar contraseña",
},
"Show status as icons only": {
def: "Show status as icons only",
es: "Mostrar estado solo con íconos",
@@ -9929,6 +9806,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "目标模式",
"zh-tw": "目標模式",
},
"Test connection and save": {
def: "Test connection and save",
es: "Probar la conexión y guardar",
},
"Test Settings and Continue": {
def: "Test Settings and Continue",
es: "Probar los ajustes y continuar",
@@ -9947,6 +9828,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 +10035,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw":
"此功能可在裝置之間直接同步,無需伺服器;但同步時兩台裝置必須同時在線,且部分功能可能受限。網際網路連線僅用於訊號交換(偵測對端),不用於資料傳輸。",
},
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.":
{
def: "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
es: "Esta primera configuración consta de varios pasos breves, ya que confirma el cifrado, el método de conexión y qué dispositivo aporta los datos iniciales. Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.",
},
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": {
def: "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
es: "Esta es una opción avanzada para usuarios que no disponen de un URI o que desean configurar parámetros detallados。",
@@ -10176,6 +10066,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "这是最符合当前设计的同步方式,所有功能均可用。你需要事先部署好 CouchDB 实例。",
"zh-tw": "這是最符合目前設計的同步方式,所有功能皆可使用。你需要事先部署好 CouchDB 實例。",
},
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.":
{
def: "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
es: "Esta comprobación opcional usa la API interna de solicitudes de Obsidian y envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un servidor de confianza; puede requerir acceso de administrador.",
},
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": {
def: "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
es: "Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar",
@@ -10522,6 +10417,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "Use Remote Configuration",
"zh-tw": "使用遠端設定",
},
"Ui.Common.LocalDatabaseInitialisationFailed": {
def: "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
},
"Ui.Common.Signal.Caution": {
def: "CAUTION",
es: "PRECAUCIÓN",
@@ -10550,6 +10448,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "警告",
"zh-tw": "警告",
},
"Ui.Common.SomeFilesCouldNotBeSynchronised": {
def: "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
},
"Ui.Settings.Advanced.LocalDatabaseTweak": {
def: "Local Database Tweak",
es: "Ajuste fino de la base de datos local",
@@ -11871,6 +11772,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 +12737,20 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
"zh-tw":
"只有在特殊情況下才應執行此操作,例如伺服器資料已完全損毀、其他所有裝置上的變更都已不再需要,或資料庫大小相對於 Vault 大小已變得異常龐大時。",
},
"you wanted(Thank you)!": {
def: "you wanted(Thank you)!",
es: "tu solicitud (¡gracias!)",
},
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.":
{
es: "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
ja: "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
ko: "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
ru: "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
zh: "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"zh-tw":
"(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
},
"Compute revisions for chunks (Previous behaviour)": {
es: "Calcular revisiones para chunks (comportamiento anterior)",
},
+1 -6
View File
@@ -2,7 +2,7 @@
"(Active)": "(Aktiv)",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Leer lassen, um alle Dateien zu synchronisieren. Legen Sie einen Filter als regulären Ausdruck fest, um die zu synchronisierenden Dateien einzuschränken.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Wenn dies gesetzt ist, werden alle Änderungen an lokalen und Remote-Dateien übersprungen, die diesem Muster entsprechen.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Wählen Sie dies, wenn Sie die Synchronisation bereits auf einem anderen Computer oder Smartphone verwenden.) Diese Option ist geeignet, wenn Sie dieses Gerät zu einer bestehenden LiveSync-Einrichtung hinzufügen möchten.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Wählen Sie dies, wenn Sie dieses Gerät als erstes Synchronisationsgerät einrichten.) Diese Option ist geeignet, wenn Sie LiveSync neu verwenden und von Grund auf einrichten möchten.",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Die folgenden verbundenen Geräte wurden erkannt:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Eine Setup-URI ist eine einzelne Zeichenfolge, die Ihre Serveradresse und Authentifizierungsdaten enthält. Wenn Ihre Serverinstallation eine URI erzeugt hat, bietet deren Verwendung eine einfache und sichere Konfiguration。",
@@ -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",
+50 -20
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Obsolete) Use an old adapter for compatibility",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.": "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
"↑: Overwrite Remote": "↑: Overwrite Remote",
"↓: Overwrite Local": "↓: Overwrite Local",
@@ -90,6 +90,7 @@
"Check": "Check",
"Check and convert non-path-obfuscated files": "Check and convert non-path-obfuscated files",
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.",
"Check server requirements": "Check server requirements",
"Checking connection... Please wait.": "Checking connection... Please wait.",
"Chunks": "Chunks",
"Close": "Close",
@@ -121,6 +122,7 @@
"Configure Remote": "Configure Remote",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure the same server information as your other devices again, manually, very advanced users only.",
"Connect": "Connect",
"Connect to existing database and continue": "Connect to existing database and continue",
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})",
"Connected:": "Connected:",
"Connection Method": "Connection Method",
@@ -134,6 +136,8 @@
"Copy Report to clipboard": "Copy Report to clipboard",
"CouchDB Configuration": "CouchDB Configuration",
"CouchDB Connection Tweak": "CouchDB Connection Tweak",
"CouchDB validates the database name when you connect. The name must not be empty.": "CouchDB validates the database name when you connect. The name must not be empty.",
"Create or connect to database and continue": "Create or connect to database and continue",
"Create P2P remote": "Create P2P remote",
"Cross-platform": "Cross-platform",
"Current adapter: {adapter}": "Current adapter: {adapter}",
@@ -236,6 +240,7 @@
"End-to-End Encryption": "End-to-End Encryption",
"Endpoint URL": "Endpoint URL",
"Enhance chunk size": "Enhance chunk size",
"Enter a complete HTTP or HTTPS URL.": "Enter a complete HTTP or HTTPS URL.",
"Enter a folder prefix (optional)": "Enter a folder prefix (optional)",
"Enter Server Information": "Enter Server Information",
"Enter Setup URI": "Enter Setup URI",
@@ -303,6 +308,7 @@
"Hidden Files": "Hidden Files",
"Hide completely": "Hide completely",
"Hide not applicable items": "Hide not applicable items",
"Hide password": "Hide password",
"Higher (${local} > ${remote})": "Higher (${local} > ${remote})",
"Highlight diff": "Highlight diff",
"How to display network errors when the sync server is unreachable.": "How to display network errors when the sync server is unreachable.",
@@ -477,8 +483,8 @@
"moduleLiveSyncMain.optionResumeAndRestart": "Resume and restart Obsidian",
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
"moduleLog.pathComponentTooLong": "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
"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 +506,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 +560,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 +627,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 +636,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 +690,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 +716,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 +876,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 +917,9 @@
"Same or local only": "Same or local only",
"Save and Apply": "Save and Apply",
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.",
"Save without connecting": "Save without connecting",
"Saving will be performed forcefully after this number of seconds.": "Saving will be performed forcefully after this number of seconds.",
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.": "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
"Scan a QR Code (Recommended for mobile)": "Scan a QR Code (Recommended for mobile)",
"Scan changes": "Scan changes",
"Scan changes on customization sync": "Scan changes on customization sync",
@@ -1028,6 +1028,7 @@
"Show history": "Show history",
"Show icon only": "Show icon only",
"Show only notifications": "Show only notifications",
"Show password": "Show password",
"Show status as icons only": "Show status as icons only",
"Show status icon instead of file warnings banner": "Show status icon instead of file warnings banner",
"Show status inside the editor": "Show status inside the editor",
@@ -1069,8 +1070,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 +1100,11 @@
"This device": "This device",
"This device name": "This device name",
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.",
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.": "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
"This password is used to encrypt the connection. Use something long enough.": "This password is used to encrypt the connection. Use something long enough.",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as",
@@ -1138,10 +1143,12 @@
"TweakMismatchResolve.Title.AutoAcceptCompatible": "Auto-Accept Available",
"TweakMismatchResolve.Title.TweakResolving": "Configuration Mismatch Detected",
"TweakMismatchResolve.Title.UseRemoteConfig": "Use Remote Configuration",
"Ui.Common.LocalDatabaseInitialisationFailed": "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
"Ui.Common.Signal.Caution": "CAUTION",
"Ui.Common.Signal.Danger": "DANGER",
"Ui.Common.Signal.Notice": "NOTICE",
"Ui.Common.Signal.Warning": "WARNING",
"Ui.Common.SomeFilesCouldNotBeSynchronised": "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
"Ui.Settings.Advanced.LocalDatabaseTweak": "Local Database Tweak",
"Ui.Settings.Advanced.MemoryCache": "Memory Cache",
"Ui.Settings.Advanced.TransferTweak": "Transfer Tweak",
@@ -1330,6 +1337,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 +1477,6 @@
"You are adding this device to an existing synchronisation setup.": "You are adding this device to an existing synchronisation setup.",
"You can configure in the Obsidian Plugin Settings.": "You can configure in the Obsidian Plugin Settings.",
"You should create a new synchronisation destination and rebuild your data there.": "You should create a new synchronisation destination and rebuild your data there.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size."
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.",
"you wanted(Thank you)!": "you wanted(Thank you)!"
}
+22 -25
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Obsoleto) Usar adaptador antiguo",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Déjelo vacío para sincronizar todos los archivos. Defina un filtro como expresión regular para limitar los archivos que se sincronizan.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Si se establece, se omitirá cualquier cambio en archivos locales y remotos que coincida con este patrón.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si está configurando este dispositivo como el primer dispositivo de sincronización). Esta opción es adecuada si es nuevo en LiveSync y desea configurarlo desde cero。",
"↑: Overwrite Remote": "↑: Sobrescribir remoto",
"↓: Overwrite Local": "↓: Sobrescribir local",
@@ -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!)"
}
-16
View File
@@ -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é",
-16
View File
@@ -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": "ההצפנה אינה מופעלת",
+1 -17
View File
@@ -10,7 +10,7 @@
"(Obsolete) Use an old adapter for compatibility": "(廃止済み)古いアダプターを互換性のために利用",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正規表現)空欄で全ファイルを同期します。正規表現を指定すると、同期対象のファイルを絞り込めます。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正規表現)設定すると、これに一致するローカル/リモートファイルの変更はすべてスキップされます。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(別の PC やスマートフォンですでに同期を利用している場合に選択してください。)この端末を既存の LiveSync 構成に追加する場合に適しています。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(この端末を最初の同期端末として設定する場合に選択してください。)LiveSync を初めて利用し、最初から設定したい場合に適しています。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 次の接続済みデバイスが検出されました:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI は、サーバーアドレスと認証情報を含む 1 本の文字列です。サーバーのインストールスクリプトで生成された URI がある場合は、それを使うと簡単かつ安全に設定できます。",
@@ -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": "暗号化が有効になっていません",
+1 -17
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(사용 중단) 호환성을 위해 이전 어댑터 사용",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.",
"↑: Overwrite Remote": "↑: 원격 덮어쓰기",
"↓: Overwrite Local": "↓: 로컬 덮어쓰기",
@@ -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": "암호화가 활성화되지 않음",
+1 -17
View File
@@ -11,7 +11,7 @@
"(Obsolete) Use an old adapter for compatibility": "(Устарело) Использовать старый адаптер для совместимости",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Оставьте пустым, чтобы синхронизировать все файлы. Укажите регулярное выражение, чтобы ограничить синхронизируемые файлы.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Если задано, любые изменения локальных и удалённых файлов, соответствующих этому шаблону, будут пропускаться.",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Выберите этот вариант, если вы уже используете синхронизацию на другом компьютере или смартфоне.) Он подходит, если вы хотите добавить это устройство к уже существующей конфигурации LiveSync。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Выберите этот вариант, если настраиваете это устройство как первое устройство синхронизации.) Он подходит, если вы впервые используете LiveSync и хотите настроить всё с нуля。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Обнаружены следующие подключённые устройства:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI — это одна строка текста, содержащая адрес сервера и данные аутентификации. Если URI был создан скриптом установки сервера, его использование обеспечивает простую и безопасную настройку。",
@@ -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": "Шифрование не включено",
+1 -16
View File
@@ -15,7 +15,7 @@
"(Obsolete) Use an old adapter for compatibility": "(已淘汰)使用舊版轉接器以維持相容性",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正則表示式)留空即同步所有檔案。設定正則表示式可限制要同步的檔案。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正則表示式)若已設定,所有符合此模式的本機與遠端檔案變更都會被略過。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已經在另一台電腦或手機上使用同步,請選擇此項。)此選項適合將目前裝置加入既有 LiveSync 設定的使用者。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在將此裝置設定為第一台同步裝置,請選擇此項。)此選項適合初次使用 LiveSync,並希望從頭開始設定的使用者。",
"↑: Overwrite Remote": "↑:覆寫遠端",
"↓: Overwrite Local": "↓:覆寫本機",
@@ -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": "尚未啟用加密",
+1 -17
View File
@@ -10,7 +10,7 @@
"(Obsolete) Use an old adapter for compatibility": "(已弃用)为兼容性使用旧适配器",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(正则表达式)留空表示同步所有文件。可设置正则表达式来限制需要同步的文件。",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(正则表达式)如果已设置,则所有匹配此模式的本地和远端文件变更都会被跳过。",
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(如果你已经在另一台电脑或手机上使用同步,请选择此项。)此选项适合将当前设备加入现有 LiveSync 配置的用户。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(如果你正在将此设备配置为第一台同步设备,请选择此项。)此选项适合初次使用 LiveSync,并希望从头开始配置的用户。",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- 已检测到以下已连接设备:\n${devices}",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Setup URI 是一段包含服务器地址与认证信息的文本。如果服务器安装脚本已经生成了 URI,使用它可以更简单且更安全地完成配置。",
@@ -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": "尚未启用加密",
+1 -8
View File
@@ -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"
+63 -42
View File
@@ -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.
@@ -723,9 +732,11 @@ moduleLiveSyncMain:
moduleLocalDatabase:
logWaitingForReady: Waiting for ready...
moduleLog:
pathComponentTooLong: >-
A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on
some Android and Linux file systems: ${components}
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 +830,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 +861,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 +919,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 +995,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 +1074,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 +1133,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 +1157,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 +1450,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 +1499,11 @@ Save and Apply: Save and Apply
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.:
Save settings to a markdown file. You will be notified when new settings
arrive. You can set different files by the platform.
Save without connecting: Save without connecting
Saving will be performed forcefully after this number of seconds.: Saving will be performed forcefully after this number of seconds.
Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.:
Saving without a successful connection test keeps this profile, but
automatic synchronisation may fail until the connection is corrected.
Scan changes: Scan changes
Scan changes on customization sync: Scan changes on customization sync
Scan customization automatically: Scan customization automatically
@@ -1780,6 +1765,7 @@ Sync: Sync
Sync once: Sync once
Syncing...: Syncing...
Test Settings and Continue: Test Settings and Continue
Test connection and save: Test connection and save
The connection to the server has been configured successfully. As the next step,:
The connection to the server has been configured successfully. As the next
step,
@@ -1830,6 +1816,7 @@ Show full banner: Show full banner
Show history: Show history
Show icon only: Show icon only
Show only notifications: Show only notifications
Show password: Show password
Show status as icons only: Show status as icons only
Show status icon instead of file warnings banner: Show status icon instead of file warnings banner
Show status inside the editor: Show status inside the editor
@@ -1906,9 +1893,17 @@ This can isolate your connections between devices. Use the same Room ID for the
the same devices.
This device: This device
This device name: This device name
This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.:
This first setup has several short steps because it confirms encryption,
the connection method, and which device provides the initial data. Once it
is complete, additional devices can reuse a Setup URI.
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
This is an extremely powerful operation. We strongly recommend that you copy
your Vault folder to a safe location.
This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.:
This optional check uses Obsidian's internal request API and sends the
credentials above to the CouchDB server. Use it only with a server you
trust; administrator access may be required.
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.:
This passphrase will not be copied to another device. It will be set to
`Default` until you configure it again.
@@ -2101,7 +2096,7 @@ xxhash64 (Fastest): xxhash64 (Fastest)
"I am setting this up for the first time": "I am setting this up for the first time"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
"I am adding a device to an existing synchronisation setup": "I am adding a device to an existing synchronisation setup"
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
"(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation.": "(Select this if you already have another synchronising device.) This option adds this device to the same synchronisation."
"Yes, I want to set up a new synchronisation": "Yes, I want to set up a new synchronisation"
"Yes, I want to add this device to my existing synchronisation": "Yes, I want to add this device to my existing synchronisation"
"No, please take me back": "No, please take me back"
@@ -2134,6 +2129,8 @@ xxhash64 (Fastest): xxhash64 (Fastest)
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer."
Ui:
Common:
LocalDatabaseInitialisationFailed: Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.
SomeFilesCouldNotBeSynchronised: Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.
Signal:
Caution: CAUTION
Danger: DANGER
@@ -2340,6 +2337,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 +2446,7 @@ Ui:
Title: Choose a synchronisation remote
You can configure in the Obsidian Plugin Settings.: You can configure in the Obsidian Plugin Settings.
"you wanted(Thank you)!": "you wanted(Thank you)!"
You should create a new synchronisation destination and rebuild your data there.:
You should create a new synchronisation destination and rebuild your data
+31 -48
View File
@@ -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
-39
View File
@@ -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é
-33
View File
@@ -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: ההצפנה אינה מופעלת
+1 -28
View File
@@ -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": "いいえ、前に戻ります"
+1 -28
View File
@@ -14,7 +14,7 @@
(Obsolete) Use an old adapter for compatibility: (사용 중단) 호환성을 위해 이전 어댑터 사용
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.: (정규식) 비워 두면 모든 파일을 동기화합니다. 정규식을 지정하면 동기화할 파일을 제한할 수 있습니다.
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.: (정규식) 설정하면 이 패턴과 일치하는 로컬 및 원격 파일 변경은 모두 건너뜁니다.
(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.: (다른 컴퓨터나 스마트폰에서 이미 동기화를 사용 중이라면 선택하세요.) 이 기기를 기존 LiveSync 구성에 추가하려는 경우에 적합합니다.
(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.: (이 기기를 첫 번째 동기화 기기로 설정한다면 선택하세요.) LiveSync를 처음 사용하며 처음부터 설정하려는 경우에 적합합니다.
"↑: Overwrite Remote": "↑: 원격 덮어쓰기"
"↓: Overwrite Local": "↓: 로컬 덮어쓰기"
@@ -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: 암호화가 활성화되지 않음
+1 -17
View File
@@ -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": "Нет, верните меня назад"
+1 -27
View File
@@ -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": "不,返回上一步"
+1 -28
View File
@@ -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": "不,返回上一步"
+18
View File
@@ -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();
});
});
+26
View File
@@ -0,0 +1,26 @@
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
export interface OversizedPathComponent {
component: string;
utf8Bytes: number;
}
const utf8Encoder = new TextEncoder();
/**
* Return path components which exceed the conservative Android/Linux
* compatibility boundary.
*
* Obsidian paths use forward slashes. The limit applies to each file or
* folder name, not to the combined Vault-relative path.
*/
export function findPathComponentsExceedingUtf8Limit(
path: string,
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
): OversizedPathComponent[] {
return path
.split("/")
.filter((component) => component.length > 0)
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
findPathComponentsExceedingUtf8Limit,
} from "./pathCompatibility.ts";
describe("findPathComponentsExceedingUtf8Limit", () => {
it("accepts 255 UTF-8 bytes and reports 256 UTF-8 bytes", () => {
expect(findPathComponentsExceedingUtf8Limit("a".repeat(255))).toEqual([]);
expect(findPathComponentsExceedingUtf8Limit("a".repeat(256))).toEqual([
{
component: "a".repeat(256),
utf8Bytes: 256,
},
]);
});
it("counts UTF-8 bytes rather than JavaScript characters", () => {
expect(findPathComponentsExceedingUtf8Limit("界".repeat(85))).toEqual([]);
expect(findPathComponentsExceedingUtf8Limit(`${"界".repeat(85)}a`)).toEqual([
{
component: `${"界".repeat(85)}a`,
utf8Bytes: 256,
},
]);
});
it("does not apply the component limit to the whole path", () => {
const path = `${"a".repeat(200)}/${"b".repeat(200)}`;
expect(new TextEncoder().encode(path).byteLength).toBeGreaterThan(
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
);
expect(findPathComponentsExceedingUtf8Limit(path)).toEqual([]);
});
it("reports an oversized folder component as well as an oversized file name", () => {
const folder = "界".repeat(86);
const file = `${"b".repeat(256)}.md`;
expect(findPathComponentsExceedingUtf8Limit(`parent/${folder}/${file}`)).toEqual([
{ component: folder, utf8Bytes: 258 },
{ component: file, utf8Bytes: 259 },
]);
});
});
@@ -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)
);
});
});
+161
View File
@@ -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(),
},
});
}
+248
View File
@@ -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();
});
});
+333
View File
@@ -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));
};
}
+16
View File
@@ -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";
@@ -0,0 +1,43 @@
import type { RemoteDBSettings, RemotePreferredTweakResult } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { PreferredTweakProbe, PreferredTweakProbeFactory } 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 { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
/** Host environment sufficient to construct either preferred-tweak probe. */
export type PreferredTweakResourceHost = LiveSyncCouchDBReplicatorEnv;
interface PreferredTweakReplicator extends ResourceReplicator {
getRemotePreferredTweakValues(setting: RemoteDBSettings): Promise<RemotePreferredTweakResult>;
}
function createPreferredTweakProbe(
replicator: PreferredTweakReplicator,
snapshot: RemoteDBSettings
): PreferredTweakProbe {
return {
read: () => replicator.getRemotePreferredTweakValues(snapshot),
dispose: createReplicatorDisposer(replicator),
};
}
/** Build an unpublished, independently disposed CouchDB preferred-tweak probe. */
export function createCouchDBPreferredTweakProbeFactory(host: PreferredTweakResourceHost): PreferredTweakProbeFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
return Promise.resolve(createPreferredTweakProbe(new LiveSyncCouchDBReplicator(host), snapshot));
};
}
/** Build an unpublished, independently disposed Object Storage preferred-tweak probe. */
export function createObjectStoragePreferredTweakProbeFactory(
host: PreferredTweakResourceHost
): PreferredTweakProbeFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
return Promise.resolve(createPreferredTweakProbe(new LiveSyncJournalReplicator(host), snapshot));
};
}
@@ -0,0 +1,41 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SecuritySeedResourceFactory } 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 { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
/** Host environment sufficient to construct either Security Seed resource. */
export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv;
/** Minimal private Replicator surface required by a Security Seed resource. */
interface SecuritySeedReplicator extends ResourceReplicator {
getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise<Uint8Array<ArrayBuffer>>;
}
function createSecuritySeedResourceFactory(
createReplicator: () => SecuritySeedReplicator
): SecuritySeedResourceFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
const replicator = createReplicator();
return Promise.resolve({
read: () => replicator.getReplicationPBKDF2Salt(snapshot, true),
dispose: createReplicatorDisposer(replicator),
});
};
}
/** Build an unpublished, independently disposed CouchDB Security Seed resource. */
export function createCouchDBSecuritySeedResourceFactory(host: SecuritySeedResourceHost): SecuritySeedResourceFactory {
return createSecuritySeedResourceFactory(() => new LiveSyncCouchDBReplicator(host));
}
/** Build an unpublished, independently disposed Object Storage Security Seed resource. */
export function createObjectStorageSecuritySeedResourceFactory(
host: SecuritySeedResourceHost
): SecuritySeedResourceFactory {
return createSecuritySeedResourceFactory(() => new LiveSyncJournalReplicator(host));
}
+28
View File
@@ -0,0 +1,28 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/**
* Closeable surface of a concrete Replicator owned by one private resource.
*
* It deliberately exposes no active-provider controls: the resource may use
* the helper for one bounded operation, then must dispose it without
* publishing or replacing the active Replicator.
*/
export interface ResourceReplicator {
closeReplication(): void | Promise<void>;
}
/** Create one idempotent asynchronous disposer for a private Replicator. */
export function createReplicatorDisposer(replicator: ResourceReplicator): () => Promise<void> {
let disposal: Promise<void> | undefined;
return () => {
if (disposal === undefined) {
disposal = Promise.resolve().then(() => replicator.closeReplication());
}
return disposal;
};
}
/** Fence a finite resource from later edits to its source settings object. */
export function snapshotRemoteSettings(setting: RemoteDBSettings): RemoteDBSettings {
return { ...setting };
}

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