Compare commits

...

33 Commits

Author SHA1 Message Date
github-actions[bot] 809caffed9 Releasing 1.0.0-beta.4 2026-07-25 05:08:39 +00:00
vorotamoroz bea0e68091 Keep translation details out of startup path 2026-07-25 04:34:53 +00:00
vorotamoroz 99eb9cd46f Record beta.3 and current validation 2026-07-25 03:49:39 +00:00
vorotamoroz a7bc337fd1 Test Security Seed refresh in Real Obsidian 2026-07-25 03:49:18 +00:00
vorotamoroz 0d68b6530f Test Garbage Collection V3 against CouchDB 2026-07-25 03:48:51 +00:00
vorotamoroz c24fc1dc82 Update Commonlib to 0.1.0-rc.12 2026-07-24 19:06:53 +00:00
vorotamoroz c8162fd0cd Make dialogue prose selectable 2026-07-24 17:53:55 +00:00
vorotamoroz 0a7a3b1635 Harden Garbage Collection V3 validation 2026-07-24 17:53:22 +00:00
vorotamoroz 20fb027055 Protect conflict chunks during garbage collection 2026-07-24 17:09:22 +00:00
vorotamoroz 07cba4ce83 Keep unreadable revisions for explicit repair 2026-07-24 16:15:46 +00:00
vorotamoroz 658ad6dfe4 Describe saved connections consistently 2026-07-24 16:15:02 +00:00
vorotamoroz 0e5475b7e3 Limit commands to applicable contexts 2026-07-24 16:13:49 +00:00
vorotamoroz 6afeb0b409 Record beta.2 history and remove release-note links 2026-07-24 09:54:44 +00:00
vorotamoroz d489452583 Improve troubleshooting and compatible setting handling 2026-07-24 08:51:30 +00:00
vorotamoroz 058da1f34c Polish setup guide wording 2026-07-24 05:55:37 +00:00
vorotamoroz 32b72e4b10 Normalise Chinese setup guide line endings 2026-07-24 05:39:40 +00:00
vorotamoroz bdc44920ac Refresh setup guides and capture fixtures 2026-07-24 05:37:13 +00:00
vorotamoroz 24a4ebb8dd Use obsidian-test-session 0.2.5 2026-07-24 03:04:39 +00:00
vorotamoroz 68d22ade76 Use established terms in setup guidance 2026-07-23 17:38:16 +00:00
vorotamoroz bf0fc0aea8 Retire orphaned WebApp tests and classify regression guards 2026-07-23 17:21:16 +00:00
vorotamoroz 24b941f594 Group Hidden File Sync initialisation progress 2026-07-23 16:47:30 +00:00
vorotamoroz c8050322e4 Clarify remote setup and verify manual CouchDB onboarding 2026-07-23 16:46:50 +00:00
vorotamoroz 1df034f12a Refine P2P and manual setup workflows 2026-07-23 15:23:47 +00:00
vorotamoroz cd35858e01 Record published beta.1 release history 2026-07-23 10:31:05 +00:00
vorotamoroz 371d6b161a Document support requests for older Obsidian versions 2026-07-23 09:41:56 +00:00
vorotamoroz 2977287438 Stabilise relay disconnect readiness check 2026-07-23 08:16:32 +00:00
vorotamoroz f8f11f358a Handle multiple conflict revisions deterministically 2026-07-23 06:38:16 +00:00
vorotamoroz d784799969 Improve postponed conflict resolution handling
Keep unresolved conflicts visible after Not now, reopen them only on explicit requests, and clear stale warning and dialogue state when a replicated resolution arrives. Add typed provisional English messages, revision-tree regressions, specifications, and a focused Real Obsidian E2E.
2026-07-23 02:00:24 +00:00
vorotamoroz 8d82a3a00c Record beta preview history for corrected successor 2026-07-22 17:43:11 +00:00
vorotamoroz eaaf5fcdd9 Keep Obsidian CLI override guidance general 2026-07-22 17:09:25 +00:00
vorotamoroz defef55a6e Use published Commonlib 0.1.0-rc.10 2026-07-22 16:57:46 +00:00
vorotamoroz dcbf26f874 Document explicit Obsidian CLI discovery 2026-07-22 16:18:20 +00:00
vorotamoroz f24c543dd3 Persist displayed branches through conflict operations 2026-07-22 16:01:57 +00:00
169 changed files with 10652 additions and 2591 deletions
+5
View File
@@ -38,6 +38,11 @@ Before submitting a pull request, you must run verification scripts locally to e
```bash
npm run test:unit
```
- When changing the troubleshooting or recovery guides, inspect their current English UI labels and local references:
```bash
npm run inspect:troubleshooting
```
This read-only Inspector prints JSON containing `ok`, `checkedFiles`, `checkedLocalReferences`, and `errors`, and exits unsuccessfully when a contract is stale.
If you have the capability and a suitable environment (such as Linux and Docker), running the CLI End-to-End (E2E) tests is also highly appreciated. Instructions are detailed in [devs.md](devs.md). If you cannot run E2E tests locally, please explicitly ask to run the tests on the CI by stating 'Please run CI tests' in your pull request description.
+10 -12
View File
@@ -4,7 +4,7 @@
Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation.
Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling you to synchronise your notes directly between devices without relying on a server. Documentation is available for [Peer-to-Peer Synchronisation](./docs/p2p_sync_updates_2026.md).
Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling devices to exchange notes without a central data-storage server. A signalling relay is still required for peer discovery. See [How peer-to-peer synchronisation works](./docs/p2p.md).
![obsidian_live_sync_demo](https://user-images.githubusercontent.com/45774780/137355323-f57a8b09-abf2-4501-836c-8cb7d2ff24a3.gif)
@@ -19,14 +19,10 @@ Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling yo
- Compatible solutions are supported.
- Support end-to-end encryption.
- Synchronise settings, snippets, themes, and plug-ins via [Customisation Sync (Beta)](docs/settings.md#6-customisation-sync-advanced) or [Hidden File Sync](docs/tips/hidden-file-sync.md).
- Enable WebRTC peer-to-peer synchronisation without requiring a `host` (Experimental).
- This feature is still in the experimental stage. Please exercise caution when using it.
- WebRTC is a peer-to-peer synchronisation method, so **at least one device must be online to synchronise**.
- Instead of keeping your device online as a stable peer, you can use two pseudo-peers:
- [livesync-serverpeer](https://github.com/vrtmrz/livesync-serverpeer): A pseudo-client running on the server for receiving and sending data between devices.
- [webpeer](https://github.com/vrtmrz/obsidian-livesync/tree/main/src/apps/webpeer): A pseudo-client for receiving and sending data between devices.
- A pre-built instance is available at [fancy-syncing.vrtmrz.net/webpeer](https://fancy-syncing.vrtmrz.net/webpeer/) (hosted on the vrtmrz's blog site). This is also peer-to-peer. Feel free to use it.
- For more information, refer to the [English explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync-en.html) or the [Japanese explanatory article](https://fancy-syncing.vrtmrz.net/blog/0034-p2p-sync).
- Enable supported, opt-in WebRTC peer-to-peer synchronisation.
- No central data-storage server is required, but a signalling relay is still required for peer discovery.
- At least one device containing the required data must be online while another device synchronises.
- Follow the [Peer-to-Peer Setup](docs/setup_p2p.md) after reviewing the [P2P communication model](docs/p2p.md).
This plug-in may be particularly useful for researchers, engineers, and developers who need to keep their notes fully self-hosted for security reasons. It is also suitable for anyone seeking the peace of mind that comes with knowing their notes remain entirely private.
@@ -59,14 +55,14 @@ Choose a synchronisation method, prepare its server where required, then follow
1. Prepare the server. A maintained MinIO server installation guide is not currently available here, so set up an S3-compatible service or server of your choice.
2. Configure the clients by following [Object Storage Setup](docs/setup_object_storage.md).
3. Peer-to-Peer
1. No server setup is required.
1. No central data-storage server is required. The project's public signalling relay requires no server provisioning; controlled deployments can provide another compatible relay.
2. Configure the clients by following [Peer-to-Peer Setup](docs/setup_p2p.md).
Each workflow establishes ordinary note synchronisation on the first device, generates the additional-device Setup URI from that working device, and verifies synchronisation in both directions.
Each workflow establishes ordinary note synchronisation on the first device, generates a Setup URI for each additional device from that working device, and verifies synchronisation in both directions.
> [!TIP]
> Fly.io is no longer free. Fortunately, we can still use IBM Cloudant despite some limitations. Refer to [Set up IBM Cloudant](docs/setup_cloudant.md).
> We can also use peer-to-peer synchronisation without a server. Alternatively, cheap object storage like Cloudflare R2 can be used for free.
> We can also use peer-to-peer synchronisation without a central data-storage server; a signalling relay is still used for peer discovery. Alternatively, cheap object storage like Cloudflare R2 can be used for free.
> However, most importantly, we can use a server that we trust. Therefore, please set up your own server.
> CouchDB can also be run on a Raspberry Pi (please be mindful of your server's security).
@@ -102,6 +98,8 @@ To prevent file and database corruption, please avoid closing Obsidian until all
## Tips and Troubleshooting
- If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md).
- Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works.
- If Obsidian or LiveSync cannot start normally, use [Recovery and flag files](docs/recovery.md) before changing or resetting a remote database.
- Self-hosted LiveSync 1.0 requires Obsidian 1.7.2 or later. If you need to use 1.0 on an earlier Obsidian version, please [open an issue](https://github.com/vrtmrz/obsidian-livesync/issues/new?template=issue-report.md) with your version, platform, and reason for remaining on it so that we can assess whether extending support is practical. The standard Community Plugins installer otherwise selects an older compatible plug-in release.
- If you are having problems getting the plug-in working, see [Tips and Troubleshooting](docs/troubleshooting.md).
## Acknowledgements
+138
View File
@@ -0,0 +1,138 @@
import { access, readFile } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
type InspectionError = {
check: "current-label" | "local-reference" | "retired-label";
file: string;
detail: string;
};
export type TroubleshootingDocsInspection = {
ok: boolean;
checkedFiles: string[];
checkedLocalReferences: number;
errors: InspectionError[];
};
const guidePaths = ["docs/troubleshooting.md", "docs/recovery.md", "docs/tips/p2p-sync-tips.md"] as const;
const messageCataloguePath = "src/common/messagesJson/en.json";
const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu;
function repositoryRootFromThisFile(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
}
function normaliseReferenceTarget(rawTarget: string): string {
const withoutAngles = rawTarget.startsWith("<") && rawTarget.endsWith(">") ? rawTarget.slice(1, -1) : rawTarget;
return decodeURIComponent(withoutAngles);
}
function isExternalReference(target: string): boolean {
return /^(?:https?:|mailto:|obsidian:)/u.test(target);
}
async function inspectLocalReferences(
repositoryRoot: string,
documentPath: string,
document: string,
errors: InspectionError[]
): Promise<number> {
let checked = 0;
for (const match of document.matchAll(markdownLinkPattern)) {
const rawTarget = match[1];
if (!rawTarget) continue;
const target = normaliseReferenceTarget(rawTarget);
if (isExternalReference(target) || target.startsWith("#")) continue;
const [pathPart] = target.split("#", 1);
if (!pathPart) continue;
checked++;
const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart);
try {
await access(referencedPath);
} catch {
errors.push({
check: "local-reference",
file: documentPath,
detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`,
});
}
}
return checked;
}
export async function inspectTroubleshootingDocs(
repositoryRoot = repositoryRootFromThisFile()
): Promise<TroubleshootingDocsInspection> {
const errors: InspectionError[] = [];
const documents = new Map<string, string>();
for (const guidePath of guidePaths) {
documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8"));
}
const troubleshooting = documents.get("docs/troubleshooting.md")!;
const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record<
string,
string
>;
const requiredMessageKeys = [
"TweakMismatchResolve.Action.UseConfigured",
"TweakMismatchResolve.Action.UseMine",
"TweakMismatchResolve.Action.UseRemote",
"TweakMismatchResolve.Action.Dismiss",
"obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown",
] as const;
for (const messageKey of requiredMessageKeys) {
const label = catalogue[messageKey];
if (!label) {
errors.push({
check: "current-label",
file: messageCataloguePath,
detail: `The English message catalogue does not define ${messageKey}.`,
});
continue;
}
if (!troubleshooting.includes(label)) {
errors.push({
check: "current-label",
file: "docs/troubleshooting.md",
detail: `The guide does not include the current UI label '${label}'.`,
});
}
}
for (const retiredLabel of ["`Update with mine`", "`Use configured`", "`Sync settings via Markdown files`"]) {
if (troubleshooting.includes(retiredLabel)) {
errors.push({
check: "retired-label",
file: "docs/troubleshooting.md",
detail: `The guide still includes the retired label ${retiredLabel}.`,
});
}
}
let checkedLocalReferences = 0;
for (const [guidePath, document] of documents) {
checkedLocalReferences += await inspectLocalReferences(repositoryRoot, guidePath, document, errors);
}
return {
ok: errors.length === 0,
checkedFiles: [...guidePaths],
checkedLocalReferences,
errors,
};
}
async function runCli(): Promise<void> {
const result = await inspectTroubleshootingDocs();
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (!result.ok) process.exitCode = 1;
}
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
if (invokedPath === import.meta.url) {
await runCli();
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { inspectTroubleshootingDocs } from "./inspect-troubleshooting-docs";
describe("troubleshooting documentation contract", () => {
it("uses current English UI labels and resolves every local guide reference", async () => {
const result = await inspectTroubleshootingDocs();
expect(result.checkedFiles).toEqual([
"docs/troubleshooting.md",
"docs/recovery.md",
"docs/tips/p2p-sync-tips.md",
]);
expect(result.checkedLocalReferences).toBeGreaterThan(0);
expect(result.errors).toEqual([]);
expect(result.ok).toBe(true);
});
});
+11 -5
View File
@@ -60,13 +60,14 @@ To facilitate development and testing, the build process can automatically copy
### Testing Infrastructure
- ~~**Deno Tests**: Unit tests for platform-independent code (e.g., `HashManager.test.ts`)~~
- This is now obsolete, migrated to vitest.
- **Vitest**:
- **Unit Tests** (`vitest.config.unit.ts`): Unit tests run in Node.js (excluding harnesses and integration tests). Unit tests should be `*.unit.spec.ts` and placed alongside the implementation file (e.g., `ChunkFetcher.unit.spec.ts`). Executed via `npm run test:unit`.
- **Integration Tests** (`vitest.config.integration.ts`): Tests run in Node.js against a real CouchDB instance. Integration tests should be `*.integration.spec.ts` or `*.integration.test.ts` and placed alongside the implementation file (e.g., `StreamingFetch.integration.spec.ts`). Executed via `npm run test:integration`.
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you are strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
- **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer.
Regression tests remain beside the implementation which owns their contract. 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.
- **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.
@@ -153,6 +154,10 @@ Markdown conflict auto-merge should behave like a conservative three-way merge.
When in doubt, prefer the safer outcome: preserve data, keep the conflict visible, and ask the user rather than silently discarding content or choosing one side.
The detailed contract is documented in [Conflict resolution and revision provenance](docs/specs_conflict_resolution.md). Determine the merge base by intersecting the exact `available` revision IDs from both leaf histories and selecting the nearest shared revision. Do not infer ancestry from revision generation numbers. When a remote resolution reaches a Vault which still contains the exact content of a deleted losing branch, treat that content as known synchronised history so the resolution can be reflected without recreating the conflict.
File operations made while a conflict is active must use the device-local file-reflection provenance injected into `ServiceFileHandlerBase`. Treat its exact revision as authoritative; use byte equality only to reconstruct a missing record when exactly one available revision matches. If branch identity remains unknown, preserve data and leave the conflict visible. Do not hide key-value database readiness behind an implicit wait: maintained hosts open it through the sequential settings lifecycle before file events or replication begin.
- If one side deletes a line and the other side leaves that same line unchanged, treat it as a safe deletion. The deleted line must not be reintroduced into the merged result.
- If one side inserts new content in a different region while the other side deletes an unchanged old region, preserve the insertion and the deletion.
- If one side deletes a line and the other side modifies that same line, keep the conflict for user resolution.
@@ -263,7 +268,8 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
- Publish a stable draft as the latest release, or publish a pre-release draft without replacing the latest stable release. In either case, keep the release PR in draft and leave its base branch unchanged until BRAT validation succeeds. Record that state in the PR.
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
- After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. This keeps the tagged release commit in that branch's history.
- If BRAT validation fails, keep the release PR in draft. Do not move published tags; prepare and publish a new patch release instead.
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
- For a pre-release, set `prerelease=true` in `Finalise Release Tags`. A hyphenated version is rejected unless that input is enabled.
### Release Cheat Sheet
@@ -292,7 +298,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
8. Update the PR state message to describe the published release and state that merging remains on hold until BRAT validation is complete.
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
10. After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit.
11. If validation fails, leave the PR in draft and prepare a new patch release without moving the published tags.
11. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
## Contribution Guidelines
+12 -1
View File
@@ -31,7 +31,18 @@ Commit the edited YAML and all regenerated JSON and TypeScript resources togethe
## Make a message translatable
1. Add its canonical English entry and translations to `src/common/messagesYAML/`.
LiveSync-owned messages may first be added to
`src/common/messages/LiveSyncProvisionalMessages.ts` while their wording is being
exercised. This keeps an application-only message out of Commonlib and provides a
typed English fallback without requiring contributors to update every language.
When the wording is ready for translation:
1. Move its canonical English entry from
`src/common/messages/LiveSyncProvisionalMessages.ts` to
`src/common/messagesYAML/en.yaml`. Remove the provisional entry in the same
change. Translations in the other LiveSync YAML files may follow as contributor
updates.
2. Replace the source literal with `$msg()` or another existing translation helper, using the English catalogue key as the typed contract.
3. Run `npm run i18n:bake`, build the plug-in, and verify the affected workflow.
+3 -3
View File
@@ -143,7 +143,7 @@ Current verification:
- `npm run test:e2e:obsidian:couchdb-upload` configures a unique CouchDB database, creates a note through Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that CouchDB contains the metadata document and all referenced chunk documents.
- `npm run test:e2e:obsidian:minio-upload` configures a unique Object Storage bucket prefix, creates a note through Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written to the S3-compatible bucket.
- `npm run test:e2e:obsidian:startup-scan` verifies that a file written while Obsidian is stopped is picked up during the next real Obsidian boot and uploaded to CouchDB after one-shot synchronisation.
- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, rename, deletion, per-device target-filter differences, and a separate encrypted round-trip with Path Obfuscation enabled. The experimental Markdown conflict automatic merge check is available with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` but is not part of the default local suite.
- `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, rename, deletion, per-device target-filter differences, and a separate encrypted round-trip with Path Obfuscation enabled. The optional Markdown conflict check creates divergent branches in two real Vaults, conservatively merges them, edits the merged result again, and verifies that the Vault holding the deleted losing revision accepts the propagated result without recreating a conflict. Enable it with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. A separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` scope keeps four conflicts active while a Vault edits, deletes, case-renames, and cross-path-renames files, then verifies exact parent revisions and replicated trees. Neither scope is part of the default local suite.
- `npm run test:e2e:obsidian:hidden-file-snippet-sync` verifies hidden file synchronisation as a two-vault round-trip: creation, deletion, automatic JSON conflict merging with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target-pattern differences.
- `npm run test:e2e:obsidian:customisation-sync` verifies a two-vault Customisation Sync workflow: scan a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronise them through CouchDB, apply them on the second vault, assert the resulting `.obsidian` files, propagate a snippet update, and verify deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
- `npm run test:e2e:obsidian:setting-markdown-export` verifies that setting Markdown export creates a vault file and omits credentials when credential export is disabled.
@@ -186,7 +186,7 @@ Current implementation status:
Current implementation status:
- `test:e2e:obsidian:two-vault-sync` covers creation, update, rename, deletion, and per-device target-filter behaviour for a non-encrypted CouchDB configuration. Markdown conflict automatic merging remains an optional check because it needs a dedicated, less timing-sensitive fixture.
- `test:e2e:obsidian:two-vault-sync` covers creation, update, rename, deletion, and per-device target-filter behaviour for a non-encrypted CouchDB configuration. Its optional conflict fixtures cover real two-Vault divergence, conservative merge, post-resolution editing, propagation to a Vault whose file still contains the deleted losing revision, and edit, logical deletion, case-only rename, and cross-path rename while conflicts remain active.
- The same script creates a separate temporary CouchDB database and temporary vault pair for an encrypted two-vault round-trip with Path Obfuscation enabled.
### Phase 4: Harness Retirement
@@ -200,7 +200,7 @@ Current implementation status:
Current implementation status:
- The mocked Vitest browser suites, their P2P runner, their root-level relay helpers, and the manual `harness-ci` workflow have been removed after maintained suites covered the critical flows.
- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including first-device URI generation, second-device import, and two-way Vault synchronisation.
- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including URI generation on the first device, import on the second device, and two-way Vault synchronisation.
- The Obsidian compatibility implementation still needed by the Webapp has moved to `src/apps/webapp/obsidianMock.ts`; it is not a retained browser E2E Harness.
- Remaining high-value scenarios, including RedFlag and Fast Setup (Simple Fetch) variants, should be added according to their owning integration boundary rather than copied line by line from the retired suite.
@@ -113,6 +113,8 @@ Self-hosted LiveSync owns the complete multilingual catalogue, generation tools,
The canonical Commonlib key type and English fallback change with Commonlib. LiveSync may add translations for those keys without duplicating every English definition; its translator delegates absent keys to Commonlib's canonical English fallback. A separate language package remains possible only if independent consumers and release cadence later justify it; core must never depend on an application catalogue.
LiveSync-owned wording may remain in a typed, application-local provisional English map while it is being exercised. The LiveSync translator composes those keys with the generated application catalogue and the Commonlib key type. Moving a stable message into LiveSync's YAML catalogue makes it available for translation without changing Commonlib.
### Svelte dialogue hosting
The present Svelte dialogue implementation is split into three responsibilities:
@@ -65,7 +65,7 @@ The special meaning would duplicate `activeConfigurationId`, make a user-visible
### Add profile naming and full list editing to onboarding
That would make the first-run path longer and duplicate the established Remote Databases interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection.
That would make the first-run path longer and duplicate the established Saved connections interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection.
### Replace the compatibility fields immediately
@@ -77,11 +77,11 @@ Commonlib unit tests cover preserving existing profiles, opaque-ID insertion, ge
Self-hosted LiveSync unit tests cover preserving modern Setup URI profiles and their active selection, retaining legacy Setup URI and QR migration, adding CouchDB and Object Storage profiles beside an existing profile, independent P2P selection, fresh P2P selection as both main and P2P remote, and cancellation without mutation.
The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and command reopening. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate.
The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and reopening from the Setup pane. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate.
## Consequences
- Manual onboarding and the Remote Databases pane share one Commonlib profile contract.
- Manual onboarding and the Saved connections list share one Commonlib profile contract.
- Existing profiles survive reconfiguration, and a newly configured connection becomes explicitly selectable.
- Modern imports retain user-assigned profile identity and names.
- Legacy Setup URIs continue to work through an isolated compatibility boundary.
+2 -2
View File
@@ -46,7 +46,7 @@ Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close
Relay sockets retain their Trystero-provided close handlers. LiveSync pauses relay reconnection, closes the sockets, and later resumes reconnection through Trystero's public functions. It does not replace `socket.onclose`, because Trystero uses that handler to retire and recreate shared relay clients correctly.
P2P setup follows the transport's actual ownership model. First-device initialisation resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present the central-server overwrite warnings or remote-configuration option. An additional device performs one explicit peer-selection and finite Fetch pass, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice.
P2P setup follows the transport's actual ownership model. Initialising the first device resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present warnings about overwriting a central server or an option to fetch its configuration. An additional device selects a peer once, performs Fetch once, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice.
## Ownership
@@ -74,7 +74,7 @@ This interferes with Trystero's shared relay clients. The public pause and resum
## Verification
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, local-only first-device initialisation, and one-pass additional-device Fetch.
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.
@@ -60,7 +60,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex
### Onboarding activation and initialisation
- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice and the permanent command instead of opening a competing dialogue automatically.
- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice, and allow the wizard to be reopened from the Setup pane instead of opening a competing dialogue automatically.
- For new-device onboarding, reserve Rebuild before enabling and saving the accepted settings.
- For an unconfigured existing device, reserve Fetch before enabling and saving imported or manually confirmed settings.
- Suspend the current runtime after the flag has been written, apply the accepted settings through the scheduler's preparation callback, and request restart only after that callback succeeds.
+98
View File
@@ -0,0 +1,98 @@
# How peer-to-peer synchronisation works
Peer-to-peer (P2P) synchronisation transfers Vault data between LiveSync devices through WebRTC. It does not require a central database containing a copy of the Vault. It does require a signalling relay so that devices can discover one another and establish a connection.
For the procedure for the first and additional devices, see [Set up peer-to-peer synchronisation](setup_p2p.md). For connection problems, see [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md).
## Connection model
```mermaid
flowchart LR
A["Device A"] <-->|"Discovery and connection signalling"| S["Signalling relay"]
S <-->|"Discovery and connection signalling"| B["Device B"]
A <-->|"Encrypted Vault synchronisation"| B
A -.->|"Fallback encrypted WebRTC traffic"| T["TURN server"]
T -.-> B
```
The signalling relay and TURN server have different roles:
- The **signalling relay** is required for peer discovery and connection negotiation. LiveSync uses Nostr-compatible WebSocket relays for this role. The relay does not store or transfer Vault contents.
- A **TURN server** is an optional fallback. WebRTC uses it to relay the encrypted peer connection only when the devices cannot establish a direct path through their networks.
## The project's public signalling relay
The project author operates a public signalling relay as a best-effort convenience. Selecting **Use the project's public signalling relay** means that no signalling server needs to be provisioned for an ordinary setup.
The public relay:
- is not a Vault storage service;
- may observe signalling metadata, such as connection timing and network addresses;
- has no availability or log-retention guarantee; and
- can be replaced with another compatible relay at any time by updating every device in the P2P group.
Use a signalling relay which is acceptable for your privacy and availability requirements. A controlled deployment may use its own Nostr-compatible relay.
## Signalling relay and TURN server
Both settings contain server addresses, but they are not interchangeable.
| Setting | Required | Carries Vault contents | Purpose |
| --- | --- | --- | --- |
| **Signalling relay URLs** | Yes | No | Finds peers and exchanges the information needed to establish WebRTC connections. |
| **TURN server URLs** | Only when direct WebRTC connectivity fails | Encrypted WebRTC traffic | Relays traffic between peers when NAT or firewall rules prevent a direct path. |
A TURN provider cannot read LiveSync's encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust. The project does not operate an official TURN service.
## P2P Status
The **P2P Status** pane is the current Obsidian interface for P2P connections.
- After a P2P configuration exists, the command **Self-hosted LiveSync: P2P Sync : Open P2P Status** is available from the command palette.
- The P2P ribbon icon appears only after a P2P configuration exists.
- LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it.
- Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed.
The active P2P remote is selected independently from the main CouchDB or Object Storage remote. Devices can therefore use P2P alongside their main remote without replacing it.
![P2P Status on desktop](../images/p2p-setup/p2p-status-pane.png)
![P2P Status in a mobile layout](../images/p2p-setup/p2p-status-pane-mobile.png)
**Open connection** joins the signalling room and makes the device available for discovery. **Disconnect** leaves the LiveSync room, stops its P2P replication service, and closes the signalling connections. It does not delete the saved P2P profile.
Every participating device must use the same signalling relay set, Group ID, and P2P passphrase. Each device should have a distinct device name. A peer which joins after another device is already connected is advertised to that device; use **Refresh**, or reconnect the device which should be discovered, if a peer is not yet listed.
## Manual and automatic data movement
**Replicate now** performs an explicit bidirectional synchronisation with the selected peer. This is the clearest option when proving a new configuration.
**Announce changes** and **Follow changes** provide a more continuous experience:
- The source device must enable **Announce changes** before it dispatches change notifications.
- A receiving device must enable **Follow changes** for that peer before it fetches in response to those notifications.
- A notification contains no Vault data. It only asks the following peer to fetch through the encrypted P2P connection.
- Missing a notification does not make an explicit later synchronisation unsafe; **Replicate now** still compares the available data.
The peer's **More actions** menu can save these choices for that device:
- **Synchronise when this device connects** runs one synchronisation when that named peer is discovered.
- **Follow whenever this device connects** restores following for that named peer.
- **Include in the P2P synchronisation command** includes that peer when the command for registered targets is run.
![Persistent actions for a detected peer](../images/p2p-setup/guide-p2p-setup-peer-actions-menu.png)
Configure these only after a manual round trip has succeeded. Device names used by persistent rules should remain unique and stable.
## Approval and privacy
A device must approve a peer before serving its data. Permanent approval is stored; session approval lasts only for the current Obsidian session. Check the displayed device name before approving a request.
The encrypted Setup URI contains the shared P2P configuration but deliberately omits the device-specific name. Store the Setup URI and its passphrase separately, and generate a Setup URI for another device from a first device which has completed setup.
## Operational limits
- At least one device which already has the required data must be online while another device fetches it.
- P2P does not provide the continuously available central copy offered by CouchDB or Object Storage. Keep independent backups.
- Mobile operating systems may pause Obsidian in the background. Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation.
- Changing from CouchDB to P2P is not a repair operation for a stopped CouchDB setup. Diagnose the existing transport first.
+5 -60
View File
@@ -1,62 +1,7 @@
# User Guide: Peer-to-Peer Synchronisation (2026 Edition)
# Peer-to-peer synchronisation
Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates.
This address is retained for links to an earlier P2P guide. The time-specific interface description has been replaced by stable documentation:
## 1. Core Concept: Server-less Freedom
P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom."
## 2. Setting Up via P2P Status Pane
You no longer need to navigate through complex menus. Simply open the **P2P Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon.
This opens the **P2P Setup** dialogue where you can configure the essentials:
- **Room ID:** A unique identifier for your synchronisation group.
- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase.
- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`).
Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network.
*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.*
## 3. Real-time Control
The status pane in the right sidebar provides granular control over your synchronisation:
- **Active P2P Remote (new):** P2P now has its own active remote selection, separate from the normal active remote for database replication. Use the combo box next to the cog icon to choose which P2P remote configuration is active for P2P features.
- **Create P2P Remote (new):** Use the **+** button to open the P2P setup dialogue and create a dedicated P2P remote configuration. This is recommended when no P2P active remote has been selected yet.
- **Selection required (new):** If no P2P active remote is selected, the pane asks for selection before P2P target-related changes are saved.
- **Signalling Status:** Shows if you are connected to the relay (🟢 Online).
- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit.
- **Replicate now (🔄):** Start immediate bidirectional replication with a visible peer (Pull, then Push).
- **Watch (🔔/🔕):** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
- **Sync target (🔗/⛓️‍💥):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔗, highlighted) or off (⛓️‍💥). This setting is persisted in your configuration.
## 4. Replication Dialogue
If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices.
Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync.
The status card now shows a stable **Room ID suffix** above **Peer ID**. The Room ID suffix is better for identifying your P2P group, while Peer ID may change between connections.
Two actions are available per peer:
- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers.
- **Start Sync & Close** — Runs the same bidirectional synchronisation, waits for it to settle, then closes the dialogue. After a successful synchronisation, it also closes the signalling connection.
On supported mobile and desktop devices, LiveSync keeps the screen awake while this peer-selection dialogue is open and while its synchronisations finish. This is intentional: display sleep can interrupt peer discovery or connection establishment and require detection to start again. Wake Lock support remains best effort, does not keep a hidden application running in the background, and does not override operating-system sleep or suspension.
During a P2P rebuild, peer discovery and selection remain protected after the rebuild has started. Keep Obsidian visible until a peer is selected and the transfer finishes, because mobile platform restrictions can still pause or terminate a hidden application.
## 5. Syncing with Registered Targets via Command Palette
You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI.
1. Open the **Command Palette** (`Ctrl/Cmd + P`).
2. Run **"P2P: Sync with targets"**.
This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly.
*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.*
## 6. Technical Improvements in 2026
- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plug-in more stable across different platforms (Mobile, Desktop, and Web).
- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators.
- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes.
- [Set up peer-to-peer synchronisation](setup_p2p.md) for configuring the first device, generating a Setup URI for another device, approving the connection, and verifying synchronisation in both directions.
- [How peer-to-peer synchronisation works](p2p.md) for signalling, TURN, privacy, the P2P Status pane, and automatic behaviour.
- [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md) for connection troubleshooting.
+41 -6
View File
@@ -50,13 +50,13 @@ Create an ordinary test note and allow it to upload before adding another device
## Create a Setup URI for another device
Generate the additional-device Setup URI from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the bootstrap URI produced during server provisioning.
Generate a Setup URI for another device from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the Setup URI produced during server provisioning.
1. Open the Obsidian command palette on the first device.
2. Run `Self-hosted LiveSync: Copy settings as a new Setup URI`.
3. Enter a new passphrase which will protect this Setup URI, then select `OK`.
![Masked passphrase for a new additional-device Setup URI](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png)
![Masked passphrase for a new Setup URI for another device](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png)
4. Copy the resulting Setup URI, then select `OK`.
@@ -75,7 +75,7 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co
5. Paste the new Setup URI generated by the first device, enter its Setup URI passphrase, and select `Test Settings and Continue`.
6. Review `Setup Complete: Preparing to Fetch Synchronisation Data`, then select `Restart and Fetch Data`.
![Additional-device Fetch confirmation](../images/quick-setup/guide-quick-setup-second-fetch.png)
![Fetch confirmation on the additional device](../images/quick-setup/guide-quick-setup-second-fetch.png)
7. For a new or empty Vault, select `Overwrite all with remote files`. For a Vault with local work, stop and choose the appropriate strategy from the [Fast Setup guide](./tips/fast-setup.md).
@@ -83,7 +83,7 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co
8. When asked how to handle extra local files, the conservative choice is `Keep local files even if not on remote`. Select the delete option only when the local Vault is disposable and an exact remote copy is intended.
![Additional-device local file policy](../images/quick-setup/guide-quick-setup-local-file-policy.png)
![Local file policy on the additional device](../images/quick-setup/guide-quick-setup-local-file-policy.png)
9. Allow retrieval, file reflection, and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared.
@@ -100,6 +100,41 @@ Add optional features separately so that their ownership and initialisation dire
Do not enable both features for the same files.
## Manual configuration
## Configure CouchDB manually on the first device
If a Setup URI is unavailable, choose `Enter the server information manually` during onboarding. Manual configuration is an advanced path: verify the connection, encryption, remote profile, and synchronisation preset before initialising either side. After the first device works, use `Copy settings as a new Setup URI` from the command palette to add later devices through the recommended path.
Use this path when CouchDB is ready but a Setup URI is unavailable. It configures one first device through the visible onboarding dialogue; it does not provision or repair the CouchDB server. Add later devices with a Setup URI generated by this working first device instead of entering the credentials again.
1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Select the `Welcome to Self-hosted LiveSync` Notice, choose `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation.
3. On `Connection Method`, select `Configure a remote manually`, then select `Proceed with manual configuration`.
![Manual remote configuration option during onboarding](../images/couchdb-manual/guide-couchdb-manual-connection-method.png)
4. On `End-to-End Encryption`, decide how the synchronised data will be protected.
- For an ordinary new Vault, enable `End-to-End Encryption` and enter a strong Vault encryption passphrase.
- Enable `Obfuscate Properties` if remote document properties should also be concealed.
- Store the Vault encryption passphrase securely. It is separate from the passphrase used to protect a Setup URI.
![CouchDB Vault encryption settings with the passphrase masked](../images/couchdb-manual/guide-couchdb-manual-encryption.png)
5. On `Choose a synchronisation remote`, select `CouchDB`, then select `Continue to CouchDB setup`.
![CouchDB option in the list of synchronisation remotes](../images/couchdb-manual/guide-couchdb-manual-remote-selection.png)
6. Enter the complete CouchDB URL, username, password, and database name.
- Obsidian Mobile requires HTTPS. Plain HTTP is suitable only for a trusted local connection from a desktop device.
- Use credentials which are allowed to connect to the selected database and, when configuring the first device, create it if it does not exist.
![Manual CouchDB connection fields with the password masked](../images/couchdb-manual/guide-couchdb-manual-connection-details.png)
7. `Check server requirements` is optional. It sends the displayed credentials to the configured server through Obsidian's internal request API, and some checks require CouchDB administrator access. The initial check is read-only. If it offers a server change, review and confirm that individual change separately.
![Successful optional CouchDB server requirements check](../images/couchdb-manual/guide-couchdb-manual-server-requirements.png)
8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed.
9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`.
10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists.
11. A newly created database can show `Fetch Remote Configuration Failed` because it does not yet contain a saved preferred configuration. Select `Skip and proceed` only for this known new database.
12. Acknowledge `All optional features are disabled`, then keep Obsidian open until the initialisation progress has cleared.
Create and synchronise an ordinary test note. Once it has reached CouchDB, follow [Create a Setup URI for another device](#create-a-setup-uri-for-another-device), then [Add another device](#add-another-device). This keeps the second device aligned with the remote profile and encryption settings which the first device actually applied.
+98
View File
@@ -0,0 +1,98 @@
# Recovery and flag files
This guide covers emergency suspension, local database recovery, and deliberate remote reconstruction. These operations are not ordinary synchronisation.
> [!IMPORTANT]
> Back up every available Vault before recovery. If a central remote is involved, back up that database or bucket as well. Stop or suspend other LiveSync devices until you have chosen the authoritative copy.
If Obsidian will not start normally, do not give up. Flag files can be created or removed with the operating system's file manager while Obsidian is closed. They are the only supported way to intervene before the ordinary LiveSync boot-up sequence reaches its database and synchronisation work.
## First choose the authoritative copy
Use the least destructive operation which matches the evidence:
- If the correct data is uncertain, suspend all work with `redflag.md`, preserve every copy, and inspect them before proceeding.
- If the central remote is healthy and should win, use **Reset Synchronisation on This Device** or `flag_fetch.md`.
- If this device's Vault is healthy and should replace a damaged or unwanted central remote, use **Overwrite Server Data with This Device's Files** or `flag_rebuild.md`.
- If both the Vault and local database are healthy and the only concern is unused storage, Garbage Collection may be appropriate. It does not repair a damaged database.
Do not switch transport, enable P2P, or run Garbage Collection as a substitute for diagnosing a stopped CouchDB or Object Storage setup.
## Suspend before diagnosis
Close Obsidian completely, then create an empty file or directory named `redflag.md` at the root of the Vault. On the next start, LiveSync enters its emergency suspension state before ordinary database, file-watching, and synchronisation work continues.
While suspended:
1. Back up the Vault and any available remote data.
2. Check which device or remote contains the intended files.
3. Correct only the identified configuration or storage problem.
4. Remove `redflag.md`.
5. Start Obsidian and review the remaining suspension controls under `Hatch` -> `Scram Switches`.
The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood.
## Reset synchronisation on this device
Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it.
The readable flag is `flag_fetch.md`; the legacy name `redflag3.md` remains accepted.
On the next start, LiveSync:
1. pauses ordinary start-up work;
2. asks which remote to use when more than one remote profile exists;
3. asks how to treat existing Vault files;
4. discards and reconstructs the local LiveSync database from the selected remote; and
5. resumes only after the scheduled operation has completed or been cancelled safely.
For P2P, a source peer must be online, discovered, and selected in `P2P Rebuild`. Merely opening an empty signalling room does not complete Fetch. Closing the rebuild dialogue without selecting a peer reports failure and does not treat the local database as restored.
Review the [Fast Setup guide](tips/fast-setup.md) before using this operation on a Vault which contains unsynchronised local work.
## Overwrite server data with this device's files
Use this only when this device's Vault is the authoritative copy and the central remote should be reconstructed from it.
The readable flag is `flag_rebuild.md`; the legacy name `redflag2.md` remains accepted.
For CouchDB and Object Storage, this is destructive to the selected remote state. Other devices may still contain revisions or files which are not present in the authoritative Vault, so keep them stopped until the new remote has been verified and then reset them from that remote.
For a P2P-only setup, there is no central remote database to overwrite. Preparing the first device instead rebuilds its local LiveSync database from its Vault.
## Garbage Collection is not Rebuild
Garbage Collection removes unreferenced chunks while preserving the current database and its revision model. Use it only when:
- the Vault is healthy;
- the local LiveSync database is healthy;
- all relevant devices have synchronised; and
- the remaining historical and deletion state is understood.
Deleted documents, tombstones, live conflicts, and retained metadata are not free. Live conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere.
## Flag-file reference
Create only the flag required for the chosen operation.
| File at the Vault root | Effect |
| --- | --- |
| `redflag.md` | Suspend ordinary LiveSync work for diagnosis. It remains until removed manually. |
| `flag_fetch.md` or `redflag3.md` | Schedule **Reset Synchronisation on This Device** from the selected remote. |
| `flag_rebuild.md` or `redflag2.md` | Schedule **Overwrite Server Data with This Device's Files**, or local P2P preparation when no central remote exists. |
Flag files themselves are excluded from synchronisation. Fetch and rebuild flags are removed by the scheduled workflow after completion or cancellation; `redflag.md` is a manual emergency stop.
## When the warning continues
If LiveSync still reports emergency suspension after a recovery dialogue has closed:
1. close Obsidian completely;
2. inspect the Vault root for every name in the table above;
3. remove only flags whose intended operation has finished or been abandoned;
4. restart Obsidian; and
5. check `Hatch` -> `Scram Switches` for remaining suspended file watching or database reflection.
If the intended authoritative copy is still uncertain, leave synchronisation suspended and collect a [full report](troubleshooting.md#collect-a-report) before changing the databases again.
+51 -23
View File
@@ -12,7 +12,7 @@ The following status applies to optional and compatibility features in the 1.0 l
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Supported, opt-in | Peer-to-Peer Synchronisation, Hidden File Sync, and Customisation Sync | Maintained and covered by focused real-runtime tests. Enable them only where their separate setup and operational constraints are acceptable. |
| Maintained, advanced | Data Compression | Available as an explicit storage and bandwidth trade-off. It remains disabled by default because the measured processing and memory costs outweigh the mixed-dataset saving. |
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
| 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. |
| Icon | Description |
@@ -40,7 +40,7 @@ Internal database or settings compatibility reviews use a separate safety dialog
This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync.
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, and **Open onboarding wizard** remains available from the command palette after that Notice closes.
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**.
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.
@@ -169,9 +169,9 @@ Show verbose log. Please enable when you report the logs
## 3. Remote Configuration
### 1. Remote Server
### 1. Connection settings
Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault.
Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings** **Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault.
Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`.
@@ -185,7 +185,7 @@ Each profile has an opaque identifier and a presentation name. The name does not
Setting key: remoteType
The active remote server type. This is automatically projected to the legacy configuration when you activate a connection profile.
The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile.
### 2. Notification
@@ -314,7 +314,7 @@ These settings are configured within the CouchDB Setup dialogue when adding (`
Setting key: couchDB_URI
The URI of the CouchDB server.
Note: Only Secure (HTTPS) connections can be used on Obsidian Mobile. The URI must not end with a trailing slash.
Only secure HTTPS connections can be used on Obsidian Mobile. The setup dialogue accepts a complete HTTP or HTTPS URL and normalises it when the settings are applied.
#### Username
@@ -332,14 +332,13 @@ The password used to authenticate with CouchDB.
Setting key: couchDB_DBNAME
The name of the database.
Note: The database name cannot contain capital letters, spaces, or special characters other than `_$()+/-`, and cannot start with an underscore (`_`).
The name of the database. It must not be empty. CouchDB validates the name when the connection is attempted; the setup dialogue does not apply a narrower client-side naming rule.
#### Use Request API to avoid inevitable CORS problem
Setting key: useRequestAPI
This option is labeled **Use Internal API** in the setup dialogue. If enabled, Obsidian's internal request API will be used to bypass CORS restrictions. This is a workaround that may not be compliant with web standards and is less secure. Note that this might break in future Obsidian versions.
This option is labelled **Use Internal API** in the setup dialogue. If enabled, Obsidian's internal request API is used to bypass CORS restrictions. It sends the configured credentials to the CouchDB server through an Obsidian-owned API, so use it only with a server you trust. Configure CouchDB CORS correctly where possible; this compatibility workaround may change in future Obsidian versions.
#### Custom Headers
@@ -383,13 +382,20 @@ Setting key: jwtSub
The subject (`sub`) claim of the JWT, which should match your CouchDB username.
#### Test Database Connection
#### Connection and save actions
Open database connection. If the remote database is not found and you have permission to create a database, the database will be created.
The action depends on why the dialogue was opened:
#### Validate Database Configuration
- Onboarding for the first device uses **Create or connect to database and continue**. It may create the database when it does not exist and the supplied account has permission.
- Onboarding for an additional device uses **Connect to existing database and continue**. It does not create a missing database.
- Adding or editing a saved remote profile uses **Test connection and save**. It does not create a missing database.
- Settings mode also offers **Save without connecting**. The existing profile is updated, but automatic synchronisation may fail until the connection is corrected.
Checks and fixes any potential issues with the database config.
Onboarding requires a successful connection. It does not expose an unverified continuation action.
#### Check server requirements
This optional check reads the CouchDB server configuration through Obsidian's internal request API and sends the configured credentials to that server. Administrator access may be required. The initial check is read-only. Each offered fix names the exact CouchDB setting and proposed value, and requires separate confirmation before making that change.
#### Apply Settings
@@ -401,11 +407,11 @@ Setting key: P2P_Enabled
Enable direct peer-to-peer synchronisation via WebRTC.
#### Relay URL
#### Signalling relay URLs
Setting key: P2P_relays
The WebSocket relay server URL(s) used for coordinating P2P connections via WebRTC. Multiple URLs can be separated by commas.
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).
#### Group ID
@@ -435,17 +441,17 @@ This option is labeled **Auto Start P2P Connection** in the setup dialogue. If e
Closing a P2P connection leaves the LiveSync P2P room, stops its replication service, closes the signalling relay sockets, and pauses their automatic reconnection. An idle WebRTC connection may remain temporarily under the transport's ownership so that it can be reused, but it cannot carry traffic for the room which has been left. Connecting again resumes relay reconnection and joins a new LiveSync room.
#### Automatically broadcast changes to connected peers
#### Announce changes automatically after connecting
Setting key: P2P_AutoBroadcast
This option is labeled **Auto Broadcast Changes** in the setup dialogue. If enabled, changes will be automatically broadcasted to connected peers, requesting them to fetch the changes.
When enabled, this device notifies connected peers after a local change. The notification contains no Vault data. A receiving peer fetches the change only when it follows this device.
#### TURN Server URLs (comma-separated)
Setting key: P2P_turnServers
A comma-separated list of TURN/STUN server URLs. Used to relay P2P connections when direct WebRTC connection fails due to strict NAT or firewalls. In most cases, these can be left blank.
A comma-separated list of TURN server URLs. TURN is an optional fallback which relays encrypted WebRTC traffic when strict NAT or firewall rules prevent a direct peer connection. It is distinct from the required signalling relay. In most environments, this field can remain blank.
#### TURN Username
@@ -555,10 +561,12 @@ Should we keep folders that do not have any files inside?
### 5. Conflict resolution (Advanced)
Conflict resolution preserves unknown local content and automatically merges only when the available revision history supplies a safe shared base. See [Conflict resolution and revision provenance](specs_conflict_resolution.md) for the revision-tree rules, stale and concurrent resolutions, binary-file limitation, and the device-local provenance used for operations while a conflict is live.
#### (BETA) Always overwrite with a newer file
Setting key: resolveConflictsByNewerFile
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 selecting the copy with the newer modification time. This can overwrite modified files and cannot establish which revision reflects the user's intent.
#### Delay conflict resolution of inactive files
@@ -690,6 +698,12 @@ Open the dialogue
#### Make report to inform the issue
#### Copy database information for a file
Select a file to copy its local database information. The command **Copy database information for the active file** performs the same inspection for the file open in the editor.
The report includes the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Paths and identifiers can still be private metadata, so review the report before sharing it.
#### Write logs into the file
Setting key: writeLogToTheFile
@@ -713,17 +727,19 @@ Stop reflecting database changes to storage files.
### 3. Recovery and Repair
#### Recreate missing chunks for all files
#### Recreate chunks for current Vault files
This will recreate chunks for all files. If there were missing chunks, this may fix the errors.
Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content.
#### Resolve All conflicted files by the newer one
Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.
After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable.
#### Verify and repair all files
Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.
Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded.
`Retry reading revision` retries the configured chunk-retrieval path without changing the revision tree. `Discard unreadable revision` is offered only for an exact current live revision which remains unreadable; after confirmation, it creates a logical deletion for that revision. Prefer recovery from another replica or backup before discarding it.
#### Check and convert non-path-obfuscated files
@@ -951,6 +967,12 @@ If disabled(toggled), chunks will be split on the UI thread (Previous behaviour)
Setting key: processSmallFilesInUIThread
If enabled, the file under 1kb will be processed in the UI thread.
#### Automatically align compatible chunk settings
Setting key: autoAcceptCompatibleTweak
Current releases enable this by default when the differences are limited to compatible chunk settings. The side with the newer recorded modification time is used for the chunk hash algorithm, chunk size, or splitter version; the remote value is used when neither side has a recorded time or the times are equal. No dialogue or database reconstruction is required. Existing content remains readable, but changing these values can reduce chunk reuse. Turn this off to review compatible differences manually. Any difference which also involves an incompatible setting always requires an explicit decision.
### 8. Compatibility (Trouble addressed)
#### Do not check configuration mismatch before replication
@@ -1025,6 +1047,12 @@ Purge all download/upload cache.
Delete all data on the remote server.
### 6. Garbage Collection V3 (CouchDB only)
Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction.
Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no live state still needs its chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
### 7. Reset
#### Delete local database to reset or uninstall Self-hosted LiveSync
+8 -8
View File
@@ -1,6 +1,6 @@
# Set up Object Storage
This guide establishes Object Storage synchronisation on a first device, generates an additional-device Setup URI from that working device, and verifies synchronisation in both directions.
This guide establishes Object Storage synchronisation on a first device, generates a Setup URI for another device from that working device, and verifies synchronisation in both directions.
Object Storage uses the S3-compatible API. Prepare the following before starting:
@@ -12,7 +12,7 @@ Object Storage uses the S3-compatible API. Prepare the following before starting
Back up every Vault involved, and do not use Obsidian Sync, iCloud synchronisation, or another synchronisation service on the same Vault.
## Generate the bootstrap Setup URI
## Generate the initial Setup URI
The public generator applies the Object Storage preset and records the connection as the selected remote profile. Run it from a trusted terminal:
@@ -40,13 +40,13 @@ Use a new bucket prefix, or a prefix whose contents you deliberately intend to r
1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice.
3. Select `I am setting this up for the first time`, then choose the recommended Setup URI method.
4. Paste the bootstrap Setup URI, enter its passphrase, and select `Test Settings and Continue`.
4. Paste the initial Setup URI, enter its passphrase, and select `Test Settings and Continue`.
![Object Storage Setup URI on the first device](../images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png)
5. Select `Restart and Initialise Server`, then read and accept the final overwrite confirmation only when this Vault is the intended source of truth.
![Object Storage first-device initialisation](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png)
![Object Storage initialisation on the first device](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png)
![Final Object Storage overwrite confirmation](../images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png)
@@ -64,7 +64,7 @@ Generate a fresh Setup URI from the working first device:
1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette.
2. Enter a new Setup URI passphrase.
![Masked passphrase for the additional-device Object Storage Setup URI](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png)
![Masked passphrase for the Object Storage Setup URI for another device](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png)
3. Copy the resulting URI.
@@ -80,7 +80,7 @@ Start with a new or separately backed-up Vault.
2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method.
3. Enter the URI generated by the first device and its passphrase.
![First-device Object Storage Setup URI entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png)
![Object Storage Setup URI from the first device entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png)
4. Select `Restart and Fetch Data`.
@@ -96,7 +96,7 @@ Start with a new or separately backed-up Vault.
Confirm that the first device's test note appears unchanged. Create a second ordinary note on the new device, wait for its journal synchronisation to finish, and confirm that it reaches the first device. Configure optional features only after this two-way check passes.
![First-device note received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png)
![Note from the first device received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png)
![Second-device note received by the first Object Storage device](../images/object-storage-setup/guide-object-storage-setup-second-to-first.png)
@@ -104,5 +104,5 @@ Confirm that the first device's test note appears unchanged. Create a second ord
- Treat the endpoint, bucket, prefix, access key, secret key, Vault passphrase, Setup URI, and Setup URI passphrase as sensitive.
- Use a distinct prefix per synchronisation set unless shared data is explicitly intended.
- Do not select first-device initialisation against an existing prefix unless replacing its contents is deliberate.
- Do not initialise the first device against an existing prefix unless replacing its contents is deliberate.
- Object Storage is not a Vault backup. Keep independent backups and test restoration separately.
+3 -3
View File
@@ -167,7 +167,7 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv
## 4. Client Setup
> [!TIP]
> Now manual configuration is not recommended for some reasons. However, if you want to do so, please use `Setup wizard`. The recommended extra configurations will be also set.
> A generated Setup URI is the recommended path because it carries the current defaults for a new Vault and the selected remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device.
### 1. Generate the setup URI on a desktop device or server
```bash
@@ -185,7 +185,7 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co
>
> If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once.
The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current new-Vault defaults, and encodes them with Commonlib's Setup URI contract.
The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current defaults for a new Vault, and encodes them with Commonlib's Setup URI contract.
You will then get the following output:
@@ -202,7 +202,7 @@ Store the Setup URI and its passphrase separately.
Follow [Quick setup](./quick_setup.md#set-up-the-first-device) for the first device. It covers the current onboarding Notice, Setup URI import, server initialisation, and the safety prompts shown for a newly provisioned database.
After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the provisioning-time bootstrap URI. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md).
After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the initial Setup URI produced during provisioning. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md).
---
+152 -152
View File
@@ -1,152 +1,152 @@
# 在你自己的服务器上设置 CouchDB
## 目录
- [配置 CouchDB](#配置-CouchDB)
- [运行 CouchDB](#运行-CouchDB)
- [Docker CLI](#docker-cli)
- [Docker Compose](#docker-compose)
- [创建数据库](#创建数据库)
- [从移动设备访问](#从移动设备访问)
- [移动设备测试](#移动设备测试)
- [设置你的域名](#设置你的域名)
---
> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档)
## 配置 CouchDB
设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)).
需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下:
```
[couchdb]
single_node=true
max_document_size = 50000000
[chttpd]
require_valid_user = true
max_http_request_size = 4294967296
[chttpd_auth]
require_valid_user = true
authentication_redirect = /_utils/session.html
[httpd]
WWW-Authenticate = Basic realm="couchdb"
enable_cors = true
[cors]
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
headers = accept, authorization, content-type, origin, referer
methods = GET, PUT, POST, HEAD, DELETE
max_age = 3600
```
## 运行 CouchDB
### Docker CLI
你可以通过指定 `local.ini` 配置运行 CouchDB:
```
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
后台运行:
```
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
### Docker Compose
创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下:
```
obsidian-livesync
├── docker-compose.yml
└── local.ini
```
可以参照以下内容编辑 `docker-compose.yml`:
```yaml
services:
couchdb:
image: couchdb
container_name: obsidian-livesync
user: 1000:1000
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=password
volumes:
- ./data:/opt/couchdb/data
- ./local.ini:/opt/couchdb/etc/local.ini
ports:
- 5984:5984
restart: unless-stopped
```
最后, 创建并启动容器:
```
# -d will launch detached so the container runs in background
docker-compose up -d
```
## 创建数据库
CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步.
1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面
2. 点击 Create Database, 然后根据个人喜好创建数据库
## 从移动设备访问
如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。
### 移动设备测试
测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案)
```
$ ssh -R 80:localhost:5984 nokey@localhost.run
Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts.
===============================================================================
Welcome to localhost.run!
Follow your favourite reverse tunnel at [https://twitter.com/localhost_run].
**You need a SSH key to access this service.**
If you get a permission denied follow Gitlab's most excellent howto:
https://docs.gitlab.com/ee/ssh/
*Only rsa and ed25519 keys are supported*
To set up and manage custom domains go to https://admin.localhost.run/
More details on custom domains (and how to enable subdomains of your custom
domain) at https://localhost.run/docs/custom-domains
To explore using localhost.run visit the documentation site:
https://localhost.run/docs/
===============================================================================
** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. **
xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run
Connection to localhost.run closed by remote host.
Connection to localhost.run closed.
```
https://xxxxxxxx.localhost.run 即为临时服务器地址。
### 设置你的域名
设置一个指向你服务器的 A 记录,并根据需要设置反向代理。
Note: 不推荐将 CouchDB 挂载到根目录
可以使用 Caddy 很方便的给服务器加上 SSL 功能
提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。
注意检查服务器日志,当心恶意访问。
# 在你自己的服务器上设置 CouchDB
## 目录
- [配置 CouchDB](#配置-CouchDB)
- [运行 CouchDB](#运行-CouchDB)
- [Docker CLI](#docker-cli)
- [Docker Compose](#docker-compose)
- [创建数据库](#创建数据库)
- [从移动设备访问](#从移动设备访问)
- [移动设备测试](#移动设备测试)
- [设置你的域名](#设置你的域名)
---
> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档)
## 配置 CouchDB
设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)).
需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下:
```
[couchdb]
single_node=true
max_document_size = 50000000
[chttpd]
require_valid_user = true
max_http_request_size = 4294967296
[chttpd_auth]
require_valid_user = true
authentication_redirect = /_utils/session.html
[httpd]
WWW-Authenticate = Basic realm="couchdb"
enable_cors = true
[cors]
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
headers = accept, authorization, content-type, origin, referer
methods = GET, PUT, POST, HEAD, DELETE
max_age = 3600
```
## 运行 CouchDB
### Docker CLI
你可以通过指定 `local.ini` 配置运行 CouchDB:
```
$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
后台运行:
```
$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb
```
*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径*
### Docker Compose
创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下:
```
obsidian-livesync
├── docker-compose.yml
└── local.ini
```
可以参照以下内容编辑 `docker-compose.yml`:
```yaml
services:
couchdb:
image: couchdb
container_name: obsidian-livesync
user: 1000:1000
environment:
- COUCHDB_USER=admin
- COUCHDB_PASSWORD=password
volumes:
- ./data:/opt/couchdb/data
- ./local.ini:/opt/couchdb/etc/local.ini
ports:
- 5984:5984
restart: unless-stopped
```
最后, 创建并启动容器:
```
# -d will launch detached so the container runs in background
docker-compose up -d
```
## 创建数据库
CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步.
1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面
2. 点击 Create Database, 然后根据个人喜好创建数据库
## 从移动设备访问
如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。
### 移动设备测试
测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案)
```
$ ssh -R 80:localhost:5984 nokey@localhost.run
Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts.
===============================================================================
Welcome to localhost.run!
Follow your favourite reverse tunnel at [https://twitter.com/localhost_run].
**You need a SSH key to access this service.**
If you get a permission denied follow Gitlab's most excellent howto:
https://docs.gitlab.com/ee/ssh/
*Only rsa and ed25519 keys are supported*
To set up and manage custom domains go to https://admin.localhost.run/
More details on custom domains (and how to enable subdomains of your custom
domain) at https://localhost.run/docs/custom-domains
To explore using localhost.run visit the documentation site:
https://localhost.run/docs/
===============================================================================
** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. **
xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run
Connection to localhost.run closed by remote host.
Connection to localhost.run closed.
```
https://xxxxxxxx.localhost.run 即为临时服务器地址。
### 设置你的域名
设置一个指向你服务器的 A 记录,并根据需要设置反向代理。
Note: 不推荐将 CouchDB 挂载到根目录
可以使用 Caddy 很方便的给服务器加上 SSL 功能
提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。
注意检查服务器日志,当心恶意访问。
+55 -38
View File
@@ -1,54 +1,44 @@
# Set up peer-to-peer synchronisation
This guide establishes a peer-to-peer synchronisation set, generates the second-device Setup URI on the working first device, and verifies synchronisation in both directions with explicit peer approval.
This guide configures a working first device through the ordinary user interface, generates a Setup URI for an additional device, and verifies synchronisation in both directions with explicit peer approval.
Peer-to-peer synchronisation has no central copy of the Vault. At least one device containing the required data must be online when another device fetches it. A Nostr-compatible signalling relay helps devices discover each other, while the Vault data travels through the peer connection.
Peer-to-peer synchronisation has no central data-storage server containing a copy of the Vault. A signalling relay is still required for peer discovery. The project's public signalling relay avoids the need to provision one for an ordinary setup; a controlled setup can use another Nostr-compatible relay. Vault data travels through the encrypted peer connection, not through the signalling relay.
See [How peer-to-peer synchronisation works](p2p.md) for the communication model, the public relay policy, and the distinction between signalling and TURN.
Before starting:
- back up both Vaults;
- prepare a signalling relay reachable by both devices;
- decide whether to use the project's public signalling relay or another relay reachable by both devices;
- ensure the networks permit a WebRTC connection, or review the [P2P troubleshooting guidance](./tips/p2p-sync-tips.md);
- disable every other synchronisation service for these Vaults; and
- keep both devices awake and Obsidian open during the initial transfer.
## Generate the bootstrap Setup URI
Run the public generator from a trusted terminal. Supply your own relay for a controlled self-hosted setup:
```sh
export remote_type=p2p
export p2p_relays=wss://relay.example.com
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
```
The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific peer name. Store the URI and its passphrase separately.
## Set up the first device
1. Install and enable Self-hosted LiveSync in the intended Vault.
2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice.
3. Select `I am setting this up for the first time`, then choose the recommended Setup URI method.
4. Paste the bootstrap Setup URI, enter its passphrase, and select `Test Settings and Continue`.
![P2P Setup URI on the first device](../images/p2p-setup/guide-p2p-setup-first-setup-uri.png)
5. Complete the first-device initialisation and final confirmation. This initialises the local LiveSync database; P2P has no central remote database to erase.
3. Select `I am setting this up for the first time`, choose manual configuration, then select `Peer-to-Peer only`.
4. In `P2P Configuration`:
- enable P2P;
- select `Use the project's public signalling relay`, or enter your own signalling relay URLs;
- generate or enter a private Group ID;
- enter a strong P2P passphrase;
- enter a unique name for this device; and
- leave automatic start and automatic announcements disabled until the manual round trip succeeds.
5. Select `Test Settings and Continue`. The test joins the signalling relay; it does not require another peer to be online.
6. Complete the initialisation and final confirmation on the first device. This initialises the local LiveSync database; P2P has no central remote database to erase.
![P2P local initialisation on the first device](../images/p2p-setup/guide-p2p-setup-first-initialise.png)
![P2P local database confirmation on the first device](../images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png)
6. Keep optional features disabled until ordinary note synchronisation works.
7. Open `Self-hosted LiveSync: Open P2P Replicator` from the command palette. Select `Open connection` if signalling is disconnected.
7. Keep optional features disabled until ordinary note synchronisation works.
8. After saving the P2P profile, open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. The P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected.
![First P2P device connected to the signalling relay](../images/p2p-setup/guide-p2p-setup-first-device-connected.png)
8. Create an ordinary test note and wait for the local LiveSync progress indicators to clear.
9. Create an ordinary test note and wait for the local LiveSync progress indicators to clear.
## Generate the second-device Setup URI
@@ -57,7 +47,7 @@ On the working first device:
1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette.
2. Enter a new Setup URI passphrase.
![Masked passphrase for the additional-device P2P Setup URI](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png)
![Masked passphrase for the P2P Setup URI for another device](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png)
3. Copy the resulting URI.
@@ -71,7 +61,7 @@ Keep the first device online. Store the new URI and its passphrase separately.
2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method.
3. Enter the Setup URI generated by the first device and its passphrase.
![First-device P2P Setup URI entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png)
![P2P Setup URI from the first device entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png)
4. Select `Restart and Fetch Data`.
@@ -83,7 +73,7 @@ Keep the first device online. Store the new URI and its passphrase separately.
![P2P local-file policy](../images/p2p-setup/guide-p2p-setup-local-file-policy.png)
6. In `P2P Rebuild`, confirm that the expected first-device name is shown, then select `Sync`.
6. In `P2P Rebuild`, confirm that the expected name of the first device is shown, then select `Sync`.
![Selecting the first device for P2P Rebuild](../images/p2p-setup/guide-p2p-setup-select-first-device.png)
@@ -93,16 +83,16 @@ Keep the first device online. Store the new URI and its passphrase separately.
8. Keep both devices open until the test note appears on the second device.
![First-device note received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png)
![Note from the first device received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png)
## Verify the return journey
Create a second ordinary note on the second device. With automatic broadcast disabled, start the next finite synchronisation explicitly:
Create a second ordinary note on the second device. Keep automatic announcements disabled, then run and verify the next synchronisation explicitly:
1. Open the P2P Replicator pane on both devices.
2. If the previous finite peer connection no longer appears, select `Disconnect` and then `Open connection` on the first device, followed by the second device. The device which joins last is advertised to devices already in the room.
1. Open `P2P Status` on both devices.
2. If a peer no longer appears, select `Disconnect` and then `Open connection` on the first device, followed by the second device. The device which joins last is advertised to devices which are already in the room.
3. On the first device, select `Refresh`, verify the second-device name, then select `Replicate now`.
4. On the second device, verify the requesting first-device name and select `Accept` or `Accept Temporarily`.
4. On the second device, verify the name of the requesting first device and select `Accept` or `Accept Temporarily`.
![Explicit return-journey connection request on the second device](../images/p2p-setup/guide-p2p-setup-connection-request-2.png)
@@ -110,7 +100,15 @@ Create a second ordinary note on the second device. With automatic broadcast dis
![Second-device note received by the first P2P device](../images/p2p-setup/guide-p2p-setup-second-to-first.png)
The two devices are now proven to share the same room, encryption settings, and data format in both directions. Configure automatic start, automatic broadcast, or optional features separately after this manual path works.
The two devices are now proven to share the same room, encryption settings, and data format in both directions.
After this manual path works, configure automatic behaviour deliberately:
- `Announce changes` on a source device dispatches change notifications while it is connected.
- `Follow changes` on the receiving device fetches after notifications from that peer.
- The peer's `More actions` menu can synchronise or follow whenever that named device connects, or include it in the P2P synchronisation command.
An announcement contains no Vault data and does not transfer a change by itself. The source must announce, the receiver must follow, and both devices must be connected.
## If a peer does not appear
@@ -118,4 +116,23 @@ The two devices are now proven to share the same room, encryption settings, and
- Select `Refresh` after the other device joins.
- Reconnect the device which should be discovered last.
- Check that the Setup URI came from the working first device and that neither device copied a peer name manually.
- Check relay reachability, WebRTC restrictions, VPNs, and TURN considerations in [Peer-to-Peer Synchronisation Tips](./tips/p2p-sync-tips.md).
- Check signalling relay reachability separately from WebRTC connectivity.
- Review VPN and TURN options in [Peer-to-Peer Synchronisation Tips](./tips/p2p-sync-tips.md).
## Controlled or self-hosted setup
The ordinary route above starts in the plug-in UI and can use the project's public signalling relay. For a controlled deployment, prepare your own Nostr-compatible relay and enter it in `Signalling relay URLs` on every device.
The public Setup URI generator is also available when configuration must be created outside Obsidian. Run it from a trusted terminal:
```sh
export remote_type=p2p
export p2p_relays=wss://relay.example.com
export p2p_room_id=<A PRIVATE ROOM ID> # Optional; generated when omitted
export p2p_passphrase=<A PRIVATE P2P PASSPHRASE> # Optional; generated when omitted
export passphrase=<A STRONG VAULT ENCRYPTION PASSPHRASE>
export uri_passphrase=<A SEPARATE SETUP URI PASSPHRASE>
deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/setup/generate_setup_uri.ts
```
The generated Setup URI contains the encrypted room, relay, and Vault settings. It deliberately omits the device-specific name. Store the URI and its passphrase separately. After importing it on the first device, continue from the initialisation step above, then generate a fresh Setup URI for an additional device from that working device.
+245
View File
@@ -0,0 +1,245 @@
# Conflict resolution and revision provenance
This document describes the conflict-resolution and file-reflection guarantees used by Self-hosted LiveSync 1.0, together with the cases which still require user judgement. The underlying revision-tree operations and injectable provenance contract are owned by `@vrtmrz/livesync-commonlib`; LiveSync owns persistent device-local composition, Vault reflection, settings, and dialogue policy.
## Revision-tree model
PouchDB stores a document as a revision tree. It selects one live leaf as the deterministic winner and reports the other live leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.
For example:
```text
A1
├── B1 ── C1 ── D1
└── B2 ── C2
```
The two live leaves are `D1` and `C2`. Their nearest shared ancestor is `A1`; neither `B1` nor `B2` is shared. A conservative three-way merge therefore compares the changes from `A1` to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.
Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed live leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.
## Implemented 1.0 guarantees
- Automatic text and structured-data merge uses the nearest `available` revision ID which is present in both leaf histories.
- Missing or compacted history stops conservative automatic merge instead of guessing a base.
- A receiving Vault file which exactly matches any available revision in the document tree is treated as previously synchronised content. This includes an ancestor below a deleted losing leaf.
- A receiving Vault file whose bytes do not match any available revision is preserved as an unsynchronised local change.
- File bytes, rather than path, size, modification time, or revision generation, determine whether content is known.
- Three or more live versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next live pair is read.
- Each device records the exact revision most recently reflected in each Vault file. An edit, deletion, or case-only rename made while a conflict is active extends that displayed branch rather than the deterministic database winner.
- A cross-path rename stores the target before logically deleting only the displayed source branch.
The all-branch history check prevents a resolved conflict from being recreated merely because the receiving Vault still contains the known losing version. If the user has edited that version again, its bytes differ and the overwrite guard preserves it.
## Resolution patterns
| State | Safe action |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Both leaves contain identical bytes | Collapse the duplicate leaf. |
| Text or structured data has an available shared base and non-overlapping changes | Perform a conservative three-way merge. |
| One side deletes content which the other leaves unchanged | Preserve the deletion. |
| One side deletes content which the other modifies | Ask the user. |
| A receiving file matches a revision available anywhere in the tree | Apply the propagated database result. |
| A receiving file matches no available revision | Preserve it and ask the user. |
| A required body or shared ancestor is missing or compacted | Ask the user. |
| Binary contents differ | Prefer an explicit user selection; semantic merge is unavailable. |
The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general **Always overwrite with a newer file** option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision.
## Unreadable revisions and repair
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. It reports exact revision identifiers and local chunk availability separately:
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
- **Discard unreadable revision** is available only for a current winner or conflict revision which remains unreadable when the action is performed. It requires confirmation and creates a logical deletion for that exact revision.
- A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision.
Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md).
A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.
### Two devices independently create the same path
If two devices create the same full synchronised path before either device has
received the other creation, the two generation-one leaves have no shared
revision. Files with the same name in different directories remain separate
paths and do not form this conflict.
When the independently created files contain identical bytes, LiveSync deletes
one duplicate leaf without synthesising merged content. A device which still
records the deleted duplicate as its displayed revision already has the same
bytes as the surviving revision, so it does not recreate the conflict. It
rebinds its device-local provenance to the surviving revision.
When the independently created files contain different bytes, conservative
three-way merge has no valid base. LiveSync therefore leaves the two versions
for manual selection; it does not guess an empty base or concatenate unrelated
files. If both versions instead descend from a revision which the devices had
previously synchronised, they are ordinary divergent branches: LiveSync may
merge non-overlapping text or structured-data changes from that shared base,
and otherwise asks the user.
## Stale and concurrent resolutions
A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another live leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple live leaves after their trees meet.
A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current live leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.
## More than two live versions
When three or more versions remain, LiveSync compares the current PouchDB winner with one conflict leaf at a time. Commonlib orders the remaining candidates by revision generation ascending, original leaf modification time ascending, then the complete revision ID in code-unit lexical order. A missing or non-finite modification time is ordered before a finite value. Modification time makes pair selection reproducible here; it does not decide which content wins.
For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining live leaves again. There is no separate persistent merge accumulator.
**Concat both** writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two live versions, that action resolves the conflict. With three or more, the new child remains live against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.
Consequently, choosing **Not now** or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the live tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the live count, and rechecks the path rather than deleting a revision which was not the one shown.
## Device-local file provenance
LiveSync composes Commonlib's injected `FileReflectionProvenance` with its local key-value database. Each device stores:
```text
path -> { revision, observedStorageMtime? }
```
`revision` identifies the exact database revision which most recently produced the displayed Vault file. `observedStorageMtime` is the raw local modification time observed after reflection. It is not rounded, combined with another device's value, or used as proof of branch identity. No content hash is persisted.
The record changes only after a successful database-to-Vault reflection or Vault-to-database write. Reading a file does not change it. The recorded revision remains authoritative even if the user edits the file to bytes which equal another branch; otherwise content equality could silently move the edit to a branch which was not displayed.
LiveSync creates the namespaced store handle during service composition, before the key-value database is open. The sequential `onSettingLoaded` lifecycle opens that database before Vault scanning, watching, or replication starts. Store operations do not wait for implicit readiness: a lifecycle violation fails promptly, avoiding an indefinite or self-referential initialisation wait. Local database reset is a transient unavailable boundary, after which scanning reconstructs derived state.
When no record exists, LiveSync may reconstruct the displayed revision only if the current Vault bytes match exactly one available revision body. No match, or identical content in multiple revisions, cannot prove branch identity.
## Operations while a conflict exists
- Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
- Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's `deleted` marker, rather than a PouchDB `_deleted` tombstone, so the deletion remains a live branch which can replicate and be resolved against the other branch.
- A case-only rename writes the new path as a child in the same document tree.
- A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch.
If an edit's base cannot be proved, LiveSync keeps the bytes as another manual-resolution branch instead of attaching them silently to the database winner. If a deletion's displayed branch cannot be proved after the file body has gone, LiveSync preserves every branch and requests conflict review. For an unproven cross-path rename, the new target remains stored and every source branch is preserved for review. These fallbacks can leave a temporary duplicate or unresolved source, but they do not discard an unproven branch.
## Interactive dialogue policy
Choosing **Not now** postpones repeated merge dialogues for the same
uninterrupted conflict episode in the current plug-in session. Ordinary file
checks and replication do not reopen the dialogue while at least one conflict
leaf remains. If the in-editor status display is enabled, the active file shows
**This file has 3 unresolved versions. They will be reviewed one pair at a
time.** for three or more live versions, using the current count, and **This
file has unresolved conflicts.** for two. Postponement therefore does not make
the conflict invisible.
The command **Resolve if conflicted.**, and selecting a file through **Pick a
file to resolve conflict**, explicitly clear the postponement and request the
dialogue again. Cancellation caused by another conflict dialogue does not count
as **Not now**. Once the document has no remaining conflicts, the episode ends;
a later conflict at the same path prompts normally. The postponement is not
persisted across a plug-in reload. Completed pairwise resolutions are persisted
in the ordinary revision tree, so a reload forgets only the postponement and
does not repeat an already committed stage.
When synchronisation supplies a resolved document, the existing incoming-file
processing event closes an open conflict dialogue for that path. The same event
rechecks the local revision tree: if no conflict leaf remains, it ends any
postponed episode and removes the active-file warning. If conflict leaves still
exist, the stale dialogue closes and the warning changes to the current live
version count. A postponed episode stays postponed; otherwise, subsequent
conflict processing may open a fresh dialogue for the current revision tree.
Each dialogue owns its completion result, so a prompt which is answered or
closed immediately still completes the waiting conflict operation; the result
does not depend on a later global listener being ready.
The end of an automatic repeat is silent. An explicit **Pick a file to resolve
conflict** request which starts with no conflicts may show one confirmation
Notice.
## Example device scenarios
### A user edits the branch shown on one device
Mac and Android have produced two branches of `shared.md`. Mac's local database selects revision `C1` as its deterministic winner, but the file currently shown in the Android Vault came from revision `C2`:
```text
A1
├── B1 ── C1 database winner
└── B2 ── C2 displayed on Android
```
Android recorded `C2` when it wrote that revision into the Vault. If the user edits the file on Android, the new revision extends `C2`:
```text
A1
├── B1 ── C1
└── B2 ── C2 ── D2 Android edit
```
After synchronisation, both devices receive `C1` and `D2` as the live branches. The edit is not moved silently onto `C1`, and ordinary conflict resolution can compare the real descendants.
### A user deletes the branch shown on one device
If Android deletes the file while it still displays `C2`, LiveSync writes a logical-deletion revision below `C2`:
```text
A1
├── B1 ── C1
└── B2 ── C2 ── D2 (deleted: true)
```
The deletion remains one side of the live conflict. The user can still choose between the content at `C1` and deleting the file. LiveSync does not delete `C1` merely because PouchDB selected it as the winner.
### A user renames a conflicted file
If the user changes only the spelling case, such as `Note.md` to `note.md`, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device.
If the user renames `draft.md` to `published.md`, LiveSync stores `published.md` before it marks the displayed `draft.md` branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other live branch of `draft.md` remains available for conflict resolution.
### A remote resolution reaches a device which still shows the losing content
Android may resolve a conflict and continue editing while Mac still shows the losing revision. When Mac receives the resolved tree, LiveSync searches every available branch and recognises Mac's unchanged bytes as content which was already synchronised below the deleted losing leaf. It can apply Android's resolution without asking Mac to resolve the same unchanged conflict again.
If the user edited the file on Mac before the resolution arrived, the bytes no longer match that historical revision. LiveSync preserves the Mac edit as an unsynchronised conflict instead of overwriting it.
### A three-version review is interrupted
Mac receives three live versions of `shared.md`. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two live versions, then chooses **Not now** on the next dialogue and closes Obsidian.
The first decision has already changed the ordinary revision tree. On restart, LiveSync reads the two surviving versions and presents only that remaining pair; it does not reconstruct the original three-version state. If another device resolves the remaining pair before or while the dialogue is open, the warning disappears and the stale dialogue closes.
### The device-local record is missing
A local-database reset removes revision provenance. On the next scan, if the Vault file matches exactly one available revision, LiveSync can reconstruct which branch was displayed and continue from it. If the bytes match multiple revisions, or no available revision, the branch remains unproved.
In that unproved state, an edit is retained as another manual-resolution branch. A deletion leaves all existing branches intact. A cross-path rename stores the target but leaves every source branch for review. The result can require an extra decision, but it does not discard data by guessing the winner.
### Start-up or reset overlaps a provenance operation
LiveSync creates the provenance handle during composition, then opens its backing store during the sequential settings lifecycle before starting scans, watchers, or replication. If the store cannot open, start-up stops rather than leaving file processing waiting indefinitely.
During reset, the store can be temporarily unavailable. A racing provenance lookup fails promptly and follows the same conservative missing-record behaviour. After reopen, scanning can reconstruct a record when one exact revision body matches the Vault file.
## Unsafe shortcuts
Do not:
- infer a common ancestor from generation numbers alone;
- assume that the PouchDB winner is the version currently displayed in the Vault;
- replace recorded displayed provenance merely because current bytes match another branch;
- discard local content when revision-history lookup fails;
- infer revision identity from path, size, modification time, or content hash without a revision ID;
- select the newest modification time unless the user has explicitly chosen that destructive policy; or
- merge overlapping text edits or unrelated binary contents automatically.
## Verification
Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks.
LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact.
The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision.
+65
View File
@@ -0,0 +1,65 @@
# Garbage Collection V3
Garbage Collection V3 is a beta maintenance operation for CouchDB remotes. It removes chunk documents which are no longer required by the current local revision tree, propagates those logical deletions to CouchDB, and then requests remote compaction.
It is not a repair operation. Use it only when the Vault, the local LiveSync database, and the CouchDB remote are healthy, every relevant device has synchronised, and recoverable backups exist.
## Supported scope
Garbage Collection V3 is available in Edge Case mode for CouchDB. It is not offered for Object Storage or P2P:
- Object Storage has a different journal and object lifecycle.
- P2P has no central database to compact and cannot provide the accepted-device progress information required by this workflow.
The operation requires **Fetch chunks on demand** to be off so that its local reachability result is not confused with chunks which exist only on the remote.
## Workflow
After the user starts Garbage Collection V3, LiveSync:
1. completes a one-shot bidirectional CouchDB synchronisation;
2. reads the accepted-device list and current progress recorded on the remote;
3. requires parseable progress information, warns when an accepted device has no current information or device progress differs, then requires explicit confirmation;
4. computes the chunks reachable from the local PouchDB revision tree;
5. creates a logical deletion for each locally present chunk which is not reachable;
6. completes a push-only replication so that those deletions reach CouchDB;
7. requests CouchDB compaction and waits for it for up to two minutes; and
8. clears the local chunk caches.
If the initial synchronisation, device inspection, or confirmation fails, the workflow stops before collection. Missing or invalid device progress is treated as a failed inspection rather than offered as a confirmation override. If push-only replication fails, the local logical deletions have already been created, but remote compaction is not started; synchronise again before retrying or using another device. A compaction failure or completion timeout is reported separately and is not also reported as a successful completion.
## Reachability rules
A chunk remains reachable when it is referenced by any of the following:
- the current database winner for a file;
- any other live conflict revision for that file;
- an available revision on either side of a live conflict which is required to describe the divergence; or
- the nearest available revision shared by both live conflict branches.
Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted.
An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible.
## Consequences
Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database.
Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available.
Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Verify and repair all files**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required.
## Verification
Commonlib tests use real in-memory PouchDB revision trees to verify:
- collection eligibility after a normal file update;
- protection of chunks shared by multiple current files;
- protection of all live conflict branches and their nearest available shared ancestor;
- eligibility of losing-branch and ancestor-only chunks after conflict resolution;
- propagation of chunk deletion to another PouchDB database; and
- recreation and propagation when the same content is written again.
Self-hosted LiveSync unit tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction.
A disposable real-CouchDB integration test verifies logical deletion on the server, retention of shared and conflict chunks, compaction completion, replication after collection, and recreation of a content-addressed chunk. CouchDB's choice and timing of physical byte reclamation remain an external database boundary, so the test does not assert a particular reduction in database size.
+3 -3
View File
@@ -74,8 +74,8 @@ All guidelines and conventions listed below are disclosed and maintained solely
- 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.
- Relay Server (P2P relays)
- A WebSocket-based coordination server used to establish direct WebRTC peer-to-peer connections. The default relay is provided by the plug-in author.
- 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
@@ -95,7 +95,7 @@ All guidelines and conventions listed below are disclosed and maintained solely
- 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).
- TURN Server (WebRTC P2P)
- A server type (Traversal Using Relays around NAT) used as a fallback to relay traffic when direct WebRTC peer-to-peer connection is blocked by strict NAT or firewalls.
- 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)
+4 -1
View File
@@ -47,7 +47,10 @@ A pattern containing only `snippets` does not admit the `.obsidian` parent, so t
![Hidden File Sync initialisation choices](../../images/hidden-file-sync/guide-hidden-file-enable.png)
2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above.
3. Keep Obsidian open while the initial scan and synchronisation finish.
3. Keep Obsidian open while the initial scan and synchronisation finish. A progress Notice appears when preparation begins and remains visible until the initial scan has finished.
![Hidden File Sync initial scan progress Notice](../../images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png)
4. Restart Obsidian when the completion Notice recommends it.
5. Confirm that the expected hidden files, and only those files, are present in the remote synchronisation state.
+44 -11
View File
@@ -10,22 +10,55 @@ authors:
# Peer-to-Peer Synchronisation Tips
For the complete first-device, Setup URI, second-device, and two-way verification procedure, see [Set up peer-to-peer synchronisation](../setup_p2p.md).
For the first device, Setup URI, additional device, and two-way verification procedure, see [Set up peer-to-peer synchronisation](../setup_p2p.md). For the communication and privacy model, see [How peer-to-peer synchronisation works](../p2p.md).
> [!IMPORTANT]
> Peer-to-peer synchronisation is a supported opt-in feature. WebRTC connectivity still depends on the networks, relays, and optional TURN servers available to every device, so a working connection cannot be guaranteed in every environment.
> P2P is a supported opt-in feature, but WebRTC connectivity still depends on the networks available to every device. A direct connection cannot be guaranteed in every environment.
## Difficulties with Peer-to-Peer Synchronisation
## A peer does not appear
It is often the case that peer-to-peer connections do not function correctly, for instance, when using mobile data services.
In such circumstances, we recommend connecting all devices to a single Virtual Private Network (VPN). It is advisable to select a service, such as Tailscale, which facilitates direct communication between peers wherever possible.
Should one be in an environment where even Tailscale is unable to connect, or where it cannot be lawfully installed, please continue reading.
Check discovery before changing any Vault settings:
## A More Detailed Explanation
1. Confirm that both devices use the same **Signalling relay URLs**, Group ID, and P2P passphrase.
2. Confirm that each device has a distinct device name.
3. Open `P2P Status` on both devices and confirm that each shows `Connected`.
4. Select `Refresh` after the other device joins.
5. If the peer remains absent, select `Disconnect`, then `Open connection` on the device which should be advertised again.
The failure of a Peer-to-Peer connection via WebRTC can be attributed to several factors. These may include an unsuccessful UDP hole-punching attempt, or an intermediary gateway intentionally terminating the connection. Troubleshooting this matter is not a simple undertaking. Furthermore, and rather unfortunately, gateway administrators are typically aware of this type of network behaviour. Whilst a legitimate purpose for such traffic can be cited, such as for web conferencing, this is often insufficient to prevent it from being blocked.
The signalling relay discovers peers; it does not prove that the networks can carry a WebRTC data connection.
This situation, however, is the primary reason that our project does not provide a TURN server. Although it is said that a TURN server within WebRTC does not decrypt communications, the project holds the view that the risk of a malicious party impersonating a TURN server must be avoided. Consequently, configuring a TURN server for relay communication is not currently possible through the user interface. Furthermore, there is no official project TURN server, which is to say, one that could be monitored by a third party.
## A peer appears but synchronisation cannot connect
We request that you provide your own server, using your own Fully Qualified Domain Name (FQDN), and subsequently enter its details into the advanced settings.
For testing purposes, Cloudflare's Real-Time TURN Service is exceedingly convenient and offers a generous amount of free data. However, it must be noted that because it is a well-known destination, such traffic is highly conspicuous. There is also a significant possibility that it may be blocked by default. We advise proceeding with caution.
WebRTC may fail when UDP hole punching is blocked by carrier-grade NAT, a firewall, a VPN policy, or an intermediary gateway.
Try these in order:
1. Put both devices on the same ordinary network and retry.
2. Remove a VPN temporarily if it blocks peer traffic, or use a trusted VPN such as Tailscale when it provides a reachable path between the devices.
3. In `P2P Configuration` -> `Advanced Settings`, configure a trusted TURN service.
TURN is a fallback for encrypted WebRTC traffic. It is different from the required signalling relay. The project does not operate an official TURN service. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume.
## A connected peer does not receive later edits
An open signalling connection does not automatically move every change.
- Use `Replicate now` to prove an explicit bidirectional round trip.
- Enable `Announce changes` on the source device before it dispatches notifications.
- Enable `Follow changes` for that source on the receiving device before it fetches in response.
- Use the peer's `More actions` menu only after the manual round trip works.
If the device was asleep, Obsidian was in the background, or the peer disconnected, run an explicit synchronisation after both devices are visible and connected.
## Mobile limitations
Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation. Wake Lock support is best effort and cannot prevent the operating system from suspending or terminating a background application.
## Collect evidence
If the same room works on one network but not another, include both network types in the report. Run `Generate full report for opening the issue with debug info`, remove credentials and private relay details, and state whether:
- both devices reached `Connected`;
- each device appeared in `Detected Peers`;
- a connection request appeared; and
- a TURN server or VPN was in use.
+140 -424
View File
@@ -1,454 +1,170 @@
# Tips and Troubleshooting
- [Tips and Troubleshooting](#tips-and-troubleshooting)
- [Tips](#tips)
- [CORS avoidance](#cors-avoidance)
- [CORS configuration with reverse proxy](#cors-configuration-with-reverse-proxy)
- [Nginx](#nginx)
- [Nginx and subdirectory](#nginx-and-subdirectory)
- [Caddy](#caddy)
- [Caddy and subdirectory](#caddy-and-subdirectory)
- [Apache](#apache)
- [Show all setting panes](#show-all-setting-panes)
- [How to resolve `Tweaks Mismatched of Changed`](#how-to-resolve-tweaks-mismatched-of-changed)
- [Notable bugs and fixes](#notable-bugs-and-fixes)
- [Binary files get bigger on iOS](#binary-files-get-bigger-on-ios)
- [Some setting name has been changed](#some-setting-name-has-been-changed)
- [Questions and Answers](#questions-and-answers)
- [How should I share the settings between multiple devices?](#how-should-i-share-the-settings-between-multiple-devices)
- [What should I enter for the passphrase of Setup-URI?](#what-should-i-enter-for-the-passphrase-of-setup-uri)
- [Why the settings of Self-hosted LiveSync itself is disabled in default?](#why-the-settings-of-self-hosted-livesync-itself-is-disabled-in-default)
- [The plug-in says `something went wrong`.](#the-plug-in-says-something-went-wrong)
- [A large number of files were deleted, and were synchronised!](#a-large-number-of-files-were-deleted-and-were-synchronised)
- [Why `Use an old adapter for compatibility` is somehow enabled in my vault?](#why-use-an-old-adapter-for-compatibility-is-somehow-enabled-in-my-vault)
- [ZIP (or any extensions) files were not synchronised. Why?](#zip-or-any-extensions-files-were-not-synchronised-why)
- [I hope to report the issue, but you said you needs `Report`. How to make it?](#i-hope-to-report-the-issue-but-you-said-you-needs-report-how-to-make-it)
- [Where can I check the log?](#where-can-i-check-the-log)
- [Why are the logs volatile and ephemeral?](#why-are-the-logs-volatile-and-ephemeral)
- [Some network logs are not written into the file.](#some-network-logs-are-not-written-into-the-file)
- [If a file were deleted or trimmed, the capacity of the database should be reduced, right?](#if-a-file-were-deleted-or-trimmed-the-capacity-of-the-database-should-be-reduced-right)
- [How to launch the DevTools](#how-to-launch-the-devtools)
- [On Desktop Devices](#on-desktop-devices)
- [On Android](#on-android)
- [On iOS, iPadOS devices](#on-ios-ipados-devices)
- [How can I use the DevTools?](#how-can-i-use-the-devtools)
- [Checking the network log](#checking-the-network-log)
- [Troubleshooting](#troubleshooting)
- [While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.](#while-using-cloudflare-tunnels-often-obsidian-api-fallback-and-524-error-occurs)
- [On the mobile device, cannot synchronise on the local network!](#on-the-mobile-device-cannot-synchronise-on-the-local-network)
- [I think that something bad happening on the vault...](#i-think-that-something-bad-happening-on-the-vault)
- [Flag Files](#flag-files)
- [Old tips](#old-tips)
# Troubleshooting
<!-- - -->
## Tips
### CORS avoidance
If we are unable to configure CORS properly for any reason (for example, if we cannot configure non-administered network devices), we may choose to ignore CORS.
To use the Obsidian API (also known as the Non-Native API) to bypass CORS, we can enable the toggle ``Use Request API to avoid `inevitable` CORS problem``.
<!-- Add **Long explanation of CORS** here for integrity -->
### CORS configuration with reverse proxy
- IMPORTANT: CouchDB handles CORS by itself. Do not process CORS on the reverse
proxy.
- Do not process `Option` requests on the reverse proxy!
- Make sure `host` and `X-Forwarded-For` headers are forwarded to the CouchDB.
- If you are using a subdirectory, make sure to handle it properly. More
detailed information is in the
[CouchDB documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html).
Minimal configurations are as follows:
#### Nginx
```nginx
location / {
proxy_pass http://localhost:5984;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
#### Nginx and subdirectory
```nginx
location /couchdb {
rewrite ^ $request_uri;
rewrite ^/couchdb/(.*) /$1 break;
proxy_pass http://localhost:5984$uri;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /_session {
proxy_pass http://localhost:5984/_session;
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
#### Caddy
```caddyfile
domain.com {
reverse_proxy localhost:5984
}
```
#### Caddy and subdirectory
```caddyfile
domain.com {
reverse_proxy /couchdb/* localhost:5984
reverse_proxy /_session/* localhost:5984/_session
}
```
#### Apache
Sorry, Apache is not recommended for CouchDB. Omit the configuration from here.
Please refer to the
[Official documentation](https://docs.couchdb.org/en/stable/best-practices/reverse-proxies.html#reverse-proxying-with-apache-http-server).
### Show all setting panes
Full pane is not shown by default. To show all panes, please toggle all in
`🧙‍♂️ Wizard` -> `Enable extra and advanced features`.
For your information, the all panes are as follows:
![All Panes](all_toggles.png)
### How to resolve `Tweaks Mismatched of Changed`
(Since v0.23.17)
If you have changed some configurations or tweaks which should be unified
between the devices, you will be asked how to reflect (or not) other devices at
the next synchronisation. It also occurs on the device itself, where changes are
made, to prevent unexpected configuration changes from unwanted propagation.\
(We may thank this behaviour if we have synchronised or backed up and restored
Self-hosted LiveSync. At least, for me so).
Following dialogue will be shown: ![Dialogue](tweak_mismatch_dialogue.png)
- If we want to propagate the setting of the device, we should choose
`Update with mine`.
- On other devices, we should choose `Use configured` to accept and use the
configured configuration.
- `Dismiss` can postpone a decision. However, we cannot synchronise until we
have decided.
Rest assured that in most cases we can choose `Use configured`. (Unless you are
certain that you have not changed the configuration).
If we see it for the first time, it reflects the settings of the device that has
been synchronised with the remote for the first time since the upgrade.
Probably, we can accept that.
<!-- Add here -->
## Notable bugs and fixes
### Binary files get bigger on iOS
- Reported at: v0.20.x
- Fixed at: v0.21.2 (Fixed but not reviewed)
- Required action: larger files will not be fixed automatically, please perform
`Verify and repair all files`. If our local database and storage are not
matched, we will be asked to apply which one.
### Some setting name has been changed
- Fixed at: v0.22.6
| Previous name | New name |
| ---------------------------- | ---------------------------------------- |
| Open setup URI | Use the copied setup URI |
| Copy setup URI | Copy current settings as a new setup URI |
| Setup Wizard | Minimal Setup |
| Check database configuration | Check and Fix database configuration |
## Questions and Answers
### How should I share the settings between multiple devices?
- Device setup:
- Using `Setup URI` is the most straightforward way.
- Setting changes during use:
- Use `Sync settings via Markdown files` on the `🔄️ Sync settings` pane.
### What should I enter for the passphrase of Setup-URI?
- Anything you like is OK. However, the recommendation is as follows:
- Include the vault (group) information.
- Include the date of operation.
- Anything random for your security.
- For example, `MyVault-20240901-r4nd0mStr1ng`.
- Why?
- The Setup-URI is encoded; that means it cannot indicate the actual settings. Hence, if you use the same passphrase for multiple vaults, you may accidentally mix up vaults.
### Why the settings of Self-hosted LiveSync itself is disabled in default?
Basically, if we configure all `additionalSuffixOfDatabaseName` the same, we can synchronise this file between multiple devices.
(`additionalSuffixOfDatabaseName` should be unique in each device, not in the synchronised vaults).
However, if we synchronise the settings of Self-hosted LiveSync itself, we may encounter some unexpected behaviours.
For example, if a setting that 'let Self-hosted LiveSync setting be excluded' is synced, it is very unlikely that things will recover automatically after this, and there is little chance we will even notice this. Even if we change our minds and change the settings back on other devices. It could get even worse if incompatible changes are automatically reflected; everything will break.
### The plug-in says `something went wrong`.
There are many cases where this is really unclear. One possibility is that the chunk fetch did not go well.
1. Restarting Obsidian sometimes helps (fetch-order problem).
2. If actually there are no chunks, please perform `Recreate missing chunks for all files` on the `🧰 Hatch` pane at the other devices. And synchronise again. (also restart Obsidian may effect).
3. If the problem persists, please perform `Verify and repair all files` on the `🧰 Hatch` pane. If our local database and storage are not matched, we will be asked to apply which one.
### A large number of files were deleted, and were synchronised!
1. Backup everything important.
- Your local vault.
- Your CouchDB database (this can be done by replicating to another database).
2. Prepare the empty vault
3. Place `redflag.md` at the top of the vault.
4. Apply the settings **BUT DO NOT PROCEED TO RESTORE YET**.
- You can use `Setup URI`, QR Code, or manually apply the settings.
5. Set `Maximum file modification time for reflected file events` in `Remediation` on the `🩹 Patches` pane.
- If you know when the files were deleted, set the time a bit before that.
- If not, bisecting may help us.
6. Delete `redflag.md`.
7. Perform `Reset Synchronisation on This Device` on the `🎛️ Maintenance` pane.
This mode is very fragile. Please be careful.
### Why `Use an old adapter for compatibility` is somehow enabled in my vault?
Because you are a compassionate and experienced user. Before v0.17.16, we used
an old adapter for the local database. At that time, current default adapter has
not been stable. The new adapter has better performance and has a new feature
like purging. Therefore, we should use new adapters and current default is so.
However, when switching from an old adapter to a new adapter, some converting or
local database rebuilding is required, and it takes some time. It was a long
time ago now, but we once inconvenienced everyone in a hurry when we changed the
format of our database. For these reasons, this toggle is automatically on if we
have upgraded from vault which using an old adapter.
When you overwrite server data with this device's files or reset synchronisation on this device again, you will be asked to
switch this.
Therefore, experienced users (especially those stable enough not to have to
overwrite server data) may have this toggle enabled in their Vault. Please
disable it when you have enough time.
### ZIP (or any extensions) files were not synchronised. Why?
It depends on Obsidian detects. May toggling `Detect all extensions` of
`File and links` (setting of Obsidian) will help us.
### I hope to report the issue, but you said you needs `Report`. How to make it?
We can copy the report to the clipboard, by performing
`Generate full report for opening the issue with debug info` command!
### Where can I check the log?
We can launch the log pane by `Show log` on the command palette. And if you have
troubled something, please enable the `Verbose Log` on the `General Setting`
pane.
`Generate full report for opening the issue with debug info` command also contains
the recent 1000 log lines, which is very helpful for debugging. Full-report is
already set to the verbose level, so it contains all the logs without enabling the
`Verbose Log` toggle.
Let me note that please be sure to remove any sensitive information before sharing the report.
However, the logs would not be kept so long and cleared when restarted. If you
want to check the logs, please enable `Write logs into the file` temporarily.
![ScreenShot](../images/write_logs_into_the_file.png)
Start with the symptom which is visible now. Do not reset a database, change transport, or enable P2P merely to see whether the problem disappears.
> [!IMPORTANT]
>
> - Writing logs into the file will impact the performance.
> - Please make sure that you have erased all your confidential information
> before reporting issue.
> If Obsidian will not start, do not give up. Close it, create `redflag.md` at the Vault root with the operating system's file manager, then follow [Recovery and flag files](recovery.md). This is the supported route for intervening before ordinary LiveSync start-up work.
### Why are the logs volatile and ephemeral?
Before changing settings:
To avoid unexpected exposure to our confidential things.
1. Back up the affected Vaults and, where possible, the remote database or bucket.
2. Stop editing on other devices.
3. Confirm that every participating device uses the intended plug-in version.
4. Identify whether the active main remote is CouchDB, Object Storage, or P2P.
5. Open `Show log` and note the first error, rather than only the final summary.
### Some network logs are not written into the file.
For a report, run `Generate full report for opening the issue with debug info`, remove credentials and private server details, and include the steps which caused the symptom.
Especially the CORS error will be reported as a general error to the plug-in for
security reasons. So we cannot detect and log it. We are only able to
investigate them by [Checking the network log](#checking-the-network-log).
## CouchDB does not connect
### If a file were deleted or trimmed, the capacity of the database should be reduced, right?
Check the connection in this order:
No, even though if files were deleted, chunks were not deleted. Self-hosted
LiveSync splits the files into multiple chunks and transfers only newly created.
This behaviour enables us to less traffic. And, the chunks will be shared
between the files to reduce the total usage of the database.
1. Confirm that the URL is complete and points to the intended server.
2. On mobile, use HTTPS with a certificate trusted by the operating system. Plain HTTP and self-signed certificates are not supported.
3. Confirm the username, password, database name, and any custom headers.
4. Confirm that the server responds outside the plug-in and that the database exists on additional devices.
5. Use the setup dialogue's connection test.
6. If basic access works, run **Check server requirements**. Its initial check is read-only. Each offered server change requires separate confirmation.
And one more thing, we can handle the conflicts on any device even though it has
happened on other devices. This means that conflicts will happen in the past,
after the time we have synchronised. Hence we cannot collect and delete the
unused chunks even though if we are not currently referenced.
Configure CouchDB CORS first. Reverse-proxy examples belong in [Set up your own CouchDB server](setup_own_server.md), alongside the rest of the server configuration.
To shrink the database size, `Overwrite Server Data with This Device's Files` is the only reliable and effective way.
But do not worry, if we have synchronised well. We have the actual and real
files. Only it takes a bit of time and traffic.
`Use Internal API` is a compatibility workaround for a trusted server. It sends the configured credentials through Obsidian's internal request API. Enable it only after checking the destination, and do not treat a fallback through that API as proof that the server or proxy is correctly configured.
### How to launch the DevTools
A Cloudflare `524` response means that Cloudflare timed out while waiting for the origin. The response may also lack the CORS headers which would have been present on an ordinary CouchDB response. Correct the long-running server or proxy request first. The advanced CouchDB option `Use timeouts instead of heartbeats` may help only when the underlying operation is otherwise healthy.
#### On Desktop Devices
For JWT-specific setup and key-format errors, see [JWT Authentication on CouchDB](tips/jwt-on-couchdb.md).
We can launch the DevTools by pressing `ctrl`+`shift`+`i` (`Command`+`shift`+`i` on Mac).
## CouchDB was working but synchronisation stopped
#### On Android
Do not switch to P2P or reset the database as the first response. Check:
Please refer to [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/).
Once the DevTools have been launched, everything operates the same as on a PC.
1. the active remote profile and connection state;
2. the plug-in version on every device;
3. the CouchDB response and server logs;
4. pending LiveSync progress indicators;
5. `Check server requirements`; and
6. the LiveSync log and full report.
#### On iOS, iPadOS devices
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.
If we have a Mac, we can inspect from Safari on the Mac. Please refer to [Inspecting iOS and iPadOS](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios).
## Files are missing or excluded
### How can I use the DevTools?
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.
#### Checking the network log
If the log reports missing chunks or a size mismatch:
1. stop editing the affected file and keep a separate copy of any readable content;
2. restart Obsidian once to rule out an interrupted fetch;
3. synchronise a device or restore a backup which still has the correct content;
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; and
6. use `Discard unreadable revision` only after confirming that the exact revision is no longer recoverable or wanted.
`Retry reading revision` does not change the revision tree. `Discard unreadable revision` creates a logical deletion for one current winner or conflict revision after rechecking it. It does not purge history or reconstruct missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
## A configuration mismatch dialogue blocks synchronisation
Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently.
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 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.
- For a mismatch found before synchronisation, `Apply settings to this device` accepts the remote values. Choose `Update remote database settings` only when this device's values are intended to become the shared values.
- When the change requires local or remote reconstruction, the action itself states that Fetch or Rebuild will follow. Make sure that the intended authoritative copy is available before choosing it.
- `Dismiss` postpones a mismatch found before synchronisation. Synchronisation remains paused until the mismatch is resolved.
![Configuration mismatch dialogue](tweak_mismatch_dialogue.png)
Historic defect notices and renamed controls are retained in the [0.25 release history](releases/0.25.md) and [legacy release history](releases/legacy.md), rather than in the current troubleshooting path.
## Setup and settings questions
### Share a configuration with another device
Generate an encrypted Setup URI from a working device. This preserves the intended remote profiles and selections while allowing the additional device to keep its own device-specific name. Store the URI and its passphrase separately.
For deliberate setting changes during normal use, use `Sync Settings via Markdown` under `Sync settings`.
### Choose a Setup URI passphrase
Use a strong passphrase which is distinct from the Vault encryption passphrase. Record enough context outside the encrypted URI to identify the intended Vault and date, but do not rely on a reused human-readable pattern alone.
### Why synchronising LiveSync's own settings is disabled by default
An automatically propagated transport, database, or exclusion setting can disable the mechanism needed to reverse it. LiveSync therefore keeps its own settings out of Customisation Sync by default. Enable that advanced behaviour only with an independent recovery path and device-specific database suffixes.
### The plug-in reports that something went wrong
Use the first specific error in `Show log` to choose the relevant section. When it names chunks or a size mismatch, follow [Files are missing or excluded](#files-are-missing-or-excluded). Do not rebuild solely from the generic final message.
### A large deletion propagated
Stop every device, preserve the available copies, and follow [Recovery and flag files](recovery.md). If the deletion time is known, `Maximum file modification time for reflected file events` under `Remediation` can limit which remote events are applied while recovering into a separate, backed-up Vault. Treat that as a forensic recovery constraint, not as an ordinary synchronisation setting.
### An old database adapter is still selected
Very old Vaults may retain the compatibility adapter until a deliberate local database migration or reset. Do not toggle it merely to troubleshoot an unrelated current failure. The history and migration notes are in the [legacy release history](releases/legacy.md).
### ZIP or another extension is not synchronised
Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, ignore rules, and size limits as described in [Files are missing or excluded](#files-are-missing-or-excluded).
## Collect a report
Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it.
When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch****Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing.
Use `Show log` for live inspection. Logs are intentionally kept in memory for a limited time to reduce accidental disclosure. Enable `Write logs into the file` only while reproducing a problem, then disable it and remove the file after review because persistent logging affects performance and may contain private data.
![Write logs into the file](../images/write_logs_into_the_file.png)
Browser security errors, particularly CORS failures, may reach the plug-in only as a general network error. Use the network inspector when the ordinary log cannot show the rejected response.
## The database remains large after files are deleted
LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it.
Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and live conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it.
`Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them.
## Inspect a network failure
### Desktop
Open Developer Tools with `Ctrl`+`Shift`+`I`, or `Command`+`Option`+`I` on macOS.
### Android
Follow Chrome's [Remote debug Android devices](https://developer.chrome.com/docs/devtools/remote-debugging/) guide.
### iOS and iPadOS
Use Safari on a Mac and follow Apple's [Inspecting iOS and iPadOS](https://developer.apple.com/documentation/safari-developer-tools/inspecting-ios) guide.
### Network evidence
1. Open the network pane.
2. Find the requests marked in red.\
2. Reproduce the failure and select the request marked in red.
![Errored](../images/devtools1.png)
3. Capture the `Headers`, `Payload`, and, `Response`. **Please be sure to keep
important information confidential**. If the `Response` contains secrets, you
can omitted that. Note: Headers contains a some credentials. **The path of
the request URL, Remote Address, authority, and authorization must be
concealed.**\
3. Record the status, timing, and a sanitised version of the headers, payload, and response.
4. Remove the request path, remote address, authority, authorisation, cookies, credentials, and response secrets before sharing.
![Concealed sample](../images/devtools2.png)
## Troubleshooting
## P2P does not connect or transfer changes
<!-- Add here -->
Use [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md). Check signalling discovery separately from the WebRTC data path, and confirm which devices announce and follow changes. P2P is not a repair step for another transport.
### While using Cloudflare Tunnels, often Obsidian API fallback and `524` error occurs.
## Obsidian or LiveSync remains suspended
A `524` error occurs when the request to the server is not completed within a
`specified time`. This is a timeout error from Cloudflare. From the reported
issue, it seems to be 100 seconds. (#627).
Follow [Recovery and flag files](recovery.md). A `redflag.md` emergency stop remains active until it is removed outside Obsidian. Fetch and rebuild flags have different, potentially destructive meanings; do not create them merely to clear a warning.
Therefore, this error returns from Cloudflare, not from the server. Hence, the
result contains no CORS field. It means that this response makes the Obsidian
API fallback.
## Further technical context
However, even if the Obsidian API fallback occurs, the request is still not
completed within the `specified time`, 100 seconds.
To solve this issue, we need to configure the timeout settings.
Please enable the toggle in `💪 Power users` -> `CouchDB Connection Tweak` ->
`Use timeouts instead of heartbeats`.
### On the mobile device, cannot synchronise on the local network!
Obsidian mobile is not able to connect to the non-secure end-point, such as
starting with `http://`. Make sure your URI of CouchDB. Also not able to use a
self-signed certificate.
### I think that something bad happening on the vault...
Place the [flag file](#flag-files) on top of the vault, and restart Obsidian. The most simple
way is to create a new note and rename it to `redflag`. Of course, we can put it
without Obsidian.
For example, if there is `redflag.md`, Self-hosted LiveSync suspends all database and storage
processes.
### Scram State and Flag Files (SCRAM Warning Loop)
The plug-in uses a **Scram state** (emergency suspension of all synchronisation processes) to prevent database corruption when severe errors or conflicts are detected. This state is often triggered or persisted by **flag files** placed at the root of the vault.
If you encounter a warning saying **"Scram detected, all sync operations are suspended per SCRAM"** or get caught in an infinite loop where the warning persists even after clicking "Resume", it is likely due to a flag file in your vault.
#### Flag Files
A flag file is a simple Markdown file located at the root of your vault. Its very existence is significant; it may be left blank or contain any text. These files are used so they can be easily placed or deleted from outside Obsidian (e.g., when Obsidian fails to launch).
| Filename | Human-Friendly Name | Description |
| ------------- | ------------------- | --------------------------------------------------------------------------------------- |
| `redflag.md` | - | Suspends all processes (activates Scram). |
| `redflag2.md` | `flag_rebuild.md` | Suspends all processes, and overwrites server data with this device's files. |
| `redflag3.md` | `flag_fetch.md` | Suspends all processes, discards the local database, and resets synchronisation on this device. |
When resetting synchronisation on this device or overwriting server data, restarting Obsidian is performed once for safety reasons. At that time, Self-hosted LiveSync uses these files to determine whether the process should be carried out. (This mechanism is especially useful on mobile devices to force cancellation if the database rebuilding fails). These files are not subject to synchronisation.
Flag-file recovery is handled before the compatibility-review dialogue. `redflag.md` keeps start-up stopped, while a cancelled recovery or one which schedules a restart also prevents a second dialogue from competing with it in that process. When fetch-all or rebuild-all completes and start-up continues, Self-hosted LiveSync can still ask for compatibility review before ordinary synchronisation resumes. Completing a recovery does not automatically acknowledge a database or settings version; use the explicit resume action after reviewing the explanation.
#### How to Resolve the Scram Loop
If you cannot disable Scram, please follow these steps:
1. Close Obsidian completely.
2. Open your system's file manager and check the root directory of your vault.
3. Locate and delete any flag files (such as `redflag.md`, `redflag2.md`, or `redflag3.md`).
4. Launch Obsidian.
5. Go to the settings dialogue -> **Hatch** -> **Scram Switches**, and manually toggle **Suspend file watching** and **Suspend database reflecting** to `false` (disabled) if they have not been reset automatically.
> [!TIP]
> This is the reason why flag files are standard `.md` files: it allows you to manage them externally. On mobile devices, you can use system file manager applications (such as the native **Files** app on iOS/iPadOS or **Files by Google** on Android) to find and delete these files to resolve a lock, or conversely, create/place a new `redflag.md` file (or directory) at the root of your vault to force-suspend synchronisation and stop Obsidian's boot-up sequence if you need to fix a database issue.
### JWT Authentication Errors
#### DataError when configuring JWT authentication
If you encounter a `DataError:` with no additional information in the logs when configuring JWT authentication, this usually indicates a private key formatting issue.
Self-hosted LiveSync requires the private key (for ES256/ES512 algorithms) to be in the **PKCS#8 PEM** format. Standard SEC1 EC private keys (which begin with `-----BEGIN EC PRIVATE KEY-----`) will trigger this error.
To resolve this, convert your private key to PKCS#8 format using the following `openssl` command:
```bash
openssl pkcs8 -topk8 -inform PEM -nocrypt -in private.key -out pkcs8.key
```
Then paste the contents of `pkcs8.key` (which begins with `-----BEGIN PRIVATE KEY-----`) into the JWT Key field.
### Old tips
- Rarely, a file in the database could be corrupted. The plug-in will not write
to local storage when a file looks corrupted. If a local version of the file
is on your device, the corruption could be fixed by editing the local file and
synchronising it. But if the file does not exist on any of your devices, then
it can not be rescued. In this case, you can delete these items from the
settings dialogue.
- To stop the boot-up sequence (eg. for fixing problems on databases), you can
put a `redflag.md` file (or directory) at the root of your vault. Tip for iOS:
a redflag directory can be created at the root of the vault using the File
application.
- Also, with `redflag2.md` placed, we can automatically overwrite server data with this device's files during the boot-up sequence. With `redflag3.md`, we
can discard only the local database and reset synchronisation on this device.
- Q: The database is growing, how can I shrink it down? A: each of the docs is
saved with their past 100 revisions for detecting and resolving conflicts.
Picturing that one device has been offline for a while, and comes online
again. The device has to compare its notes with the remotely saved ones. If
there exists a historic revision in which the note used to be identical, it
could be updated safely (like git fast-forward). Even if that is not in
revision histories, we only have to check the differences after the revision
that both devices commonly have. This is like git's conflict-resolving method.
So, We have to make the database again like an enlarged git repo if you want
to solve the root of the problem.
- And more technical Information is in the [Technical Information](tech_info.md)
- If you want to synchronise files without obsidian, you can use
[filesystem-livesync](https://github.com/vrtmrz/filesystem-livesync).
- WebClipper is also available on Chrome Web
Store:[obsidian-livesync-webclip](https://chrome.google.com/webstore/detail/obsidian-livesync-webclip/jfpaflmpckblieefkegjncjoceapakdf)
Repo is here:
[obsidian-livesync-webclip](https://github.com/vrtmrz/obsidian-livesync-webclip).
(Docs are a work in progress.)
See [Technical Information](tech_info.md) for database and synchronisation internals. Current behaviour belongs in this guide; instructions for older defects remain in the release histories.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.4",
"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",
+15 -316
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.4",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -22,7 +22,7 @@
"@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.9",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.12",
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
@@ -39,7 +39,6 @@
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"@types/deno": "^2.5.0",
@@ -57,7 +56,7 @@
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.4",
"@vrtmrz/obsidian-test-session": "0.2.5",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -2201,123 +2200,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
"integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.3.1",
"find-up": "^4.1.0",
"get-package-type": "^0.1.0",
"js-yaml": "^3.13.1",
"resolve-from": "^5.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
"integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2732,22 +2614,6 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@promptbook/utils": {
"version": "0.69.5",
"resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz",
@@ -4898,9 +4764,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.0-rc.9",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.9.tgz",
"integrity": "sha512-gwkXu+HEWnN3Ak416cA3/Fq9HaV9a+ijqRSY6rffet4jyx/HyK2eySFtE+r74qSvBXesy7MICcJJOyx5b4dzNw==",
"version": "0.1.0-rc.12",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.12.tgz",
"integrity": "sha512-pMiOL4x5pDKCYOwtH3nG+9Ra1sLjowItBUrYoHpvcrweV4NSN0OkAEMk1vxBQlKMmKtnrAdy0WuQvmsdLOWHqA==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -4973,9 +4839,9 @@
}
},
"node_modules/@vrtmrz/obsidian-test-session": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.4.tgz",
"integrity": "sha512-fyb/6xHea/w9WwWi5u9ZJol1rBnVEk+fa1yM1RzUcf6VUAzmwn5zELWtOw8ZsFTdo9QpwVuAYlRAs35AdmUzqQ==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz",
"integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6268,16 +6134,6 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
@@ -9025,16 +8881,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
@@ -9124,24 +8970,6 @@
"node": ">= 14"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -10112,23 +9940,6 @@
"node": ">=8"
}
},
"node_modules/istanbul-lib-instrument": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
"integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@babel/core": "^7.23.9",
"@babel/parser": "^7.23.9",
"@istanbuljs/schema": "^0.1.3",
"istanbul-lib-coverage": "^3.2.0",
"semver": "^7.5.4"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
@@ -11176,16 +10987,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/ltgt": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz",
@@ -11991,23 +11792,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -13634,13 +13418,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -14286,21 +14063,6 @@
"node": ">=10"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -15155,67 +14917,6 @@
}
}
},
"node_modules/vite-plugin-istanbul": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vite-plugin-istanbul/-/vite-plugin-istanbul-9.0.1.tgz",
"integrity": "sha512-zgcdcqa4r3urX+xqhMQG2uLR2s6IdklQW+acBEtLw6fvUWhuvXswYqxjHAZZ5HCfHEgRs7RH7qIZCklTLRsKLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.29.7",
"@istanbuljs/load-nyc-config": "^1.1.0",
"@types/babel__generator": "7.27.0",
"espree": "^11.2.0",
"istanbul-lib-instrument": "^6.0.3",
"picocolors": "^1.1.1",
"source-map": "^0.7.6",
"test-exclude": "^8.0.0"
},
"peerDependencies": {
"vite": ">=7"
}
},
"node_modules/vite-plugin-istanbul/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vite-plugin-istanbul/node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vite-plugin-istanbul/node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
}
},
"node_modules/vite/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -15270,6 +14971,7 @@
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
@@ -16211,7 +15913,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.0-beta.0-cli",
"version": "1.0.0-beta.4-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -16236,22 +15938,19 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.0-beta.0-webapp",
"version": "1.0.0-beta.4-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"playwright": "^1.58.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"vite": "^8.0.16",
"vite-plugin-istanbul": "^9.0.1"
"vite": "^8.0.16"
}
},
"src/apps/webpeer": {
"version": "1.0.0-beta.0-webpeer",
"version": "1.0.0-beta.4-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.51"
},
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.0-beta.0",
"version": "1.0.0-beta.4",
"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",
@@ -28,8 +28,8 @@
"i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
"unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/",
"test:unit": "vitest run --config vitest.config.unit.ts",
"inspect:troubleshooting": "tsx _tools/inspect-troubleshooting-docs.ts",
"test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts",
"test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
"test:contract:context:webapp": "vitest run --config vitest.config.unit.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
@@ -49,11 +49,14 @@
"test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts",
"test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts",
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
"test:e2e:obsidian:object-storage-setup-uri-workflow": "tsx test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts",
@@ -61,6 +64,7 @@
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
"test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts",
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
"test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts",
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
@@ -95,7 +99,6 @@
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"@types/deno": "^2.5.0",
@@ -113,7 +116,7 @@
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/parser": "8.56.1",
"@vitest/coverage-v8": "^4.1.8",
"@vrtmrz/obsidian-test-session": "0.2.4",
"@vrtmrz/obsidian-test-session": "0.2.5",
"dotenv-cli": "^11.0.0",
"esbuild": "0.28.1",
"esbuild-plugin-inline-worker": "^0.1.1",
@@ -162,7 +165,7 @@
"@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.9",
"@vrtmrz/livesync-commonlib": "0.1.0-rc.12",
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
"diff-match-patch": "^1.0.5",
"fflate": "^0.8.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.0-beta.0-cli",
"version": "1.0.0-beta.4-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -10,6 +10,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { IgnoreRules } from "./IgnoreRules";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
/**
* Initialize service modules for CLI version
@@ -93,6 +94,7 @@ export function initialiseServiceModulesCLI(
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
// Rebuilder (platform-independent)
+11 -1
View File
@@ -259,6 +259,14 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
}
openSimpleStore<T>(kind: string): SimpleStore<T> {
// Service modules are composed before onSettingLoaded opens the file-
// backed database, so handle creation must not touch it. Actual store
// operations are deliberately fail-fast: the sequential lifecycle opens
// the database before scans, watchers, or replication start. Waiting here
// could hang forever after failed initialisation, or deadlock if a future
// initialisation handler tried to use the store it was waiting to open.
// Reset is likewise a transient unavailable boundary, not a wait state;
// callers must avoid store work there because an operation may fail.
const getDB = () => {
if (!this._kvDB) {
throw new Error("KeyValueDB is not initialized yet");
@@ -293,7 +301,9 @@ export class NodeKeyValueDBService<T extends ServiceContext = ServiceContext>
.filter((key) => key >= lower && key <= upper)
.map((key) => key.substring(prefix.length));
},
db: Promise.resolve(getDB()),
get db() {
return Promise.resolve(getDB());
},
} satisfies SimpleStore<T>;
}
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
describe("NodeKeyValueDBService.openSimpleStore", () => {
it("creates a namespaced store handle before the backing database is initialised", () => {
const dependencies = {
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
databaseEvents: {
onResetDatabase: { addHandler: vi.fn() },
onDatabaseInitialisation: { addHandler: vi.fn() },
onUnloadDatabase: { addHandler: vi.fn() },
onCloseDatabase: { addHandler: vi.fn() },
},
vault: {},
} as unknown as NodeKeyValueDBDependencies;
const service = new NodeKeyValueDBService(
createServiceContext(),
dependencies,
"/tmp/obsidian-livesync-node-kv-handle-test.json"
);
expect(() => service.openSimpleStore("early-composition")).not.toThrow();
});
it("fails store operations promptly instead of waiting for lifecycle initialisation", async () => {
const dependencies = {
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
databaseEvents: {
onResetDatabase: { addHandler: vi.fn() },
onDatabaseInitialisation: { addHandler: vi.fn() },
onUnloadDatabase: { addHandler: vi.fn() },
onCloseDatabase: { addHandler: vi.fn() },
},
vault: {},
} as unknown as NodeKeyValueDBDependencies;
const service = new NodeKeyValueDBService(
createServiceContext(),
dependencies,
"/tmp/obsidian-livesync-node-kv-uninitialised-test.json"
);
const store = service.openSimpleStore("early-composition");
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
});
});
@@ -25,6 +25,48 @@ async function waitFor(description, predicate) {
throw new Error(`Timed out waiting for ${description}`);
}
async function waitForSocketOpen(socket, description) {
if (socket.readyState === WebSocket.OPEN) {
// Node can expose OPEN before it dispatches the event. Yield once so
// Trystero's previously registered onopen handler has completed before
// this probe starts the close handshake.
await delay(0);
if (socket.readyState === WebSocket.OPEN) return;
}
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timed out waiting for ${description}; readyState=${socket.readyState}`));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
socket.removeEventListener("open", onOpen);
socket.removeEventListener("close", onClose);
socket.removeEventListener("error", onError);
};
const onOpen = () => {
cleanup();
resolve();
};
const onClose = () => {
cleanup();
reject(new Error(`${description} closed before opening`));
};
const onError = () => {
cleanup();
reject(new Error(`${description} failed before opening`));
};
// Trystero installs its onopen handler while constructing the socket,
// before this observer is registered. Waiting for the actual event
// therefore establishes transport readiness without a fixed delay.
socket.addEventListener("open", onOpen, { once: true });
socket.addEventListener("close", onClose, { once: true });
socket.addEventListener("error", onError, { once: true });
});
}
const room = joinRoom(
{
appId: `livesync-relay-disconnect-probe-${Date.now()}`,
@@ -39,10 +81,11 @@ const room = joinRoom(
);
try {
await waitFor("the relay WebSocket to open", () =>
Object.values(getRelaySockets()).some((socket) => socket.readyState === WebSocket.OPEN)
);
await waitFor("the relay WebSocket to be registered", () => Object.values(getRelaySockets()).length > 0);
const originalSockets = Object.values(getRelaySockets());
await Promise.all(
originalSockets.map((socket, index) => waitForSocketOpen(socket, `relay WebSocket ${index + 1} to open`))
);
const replicator = new TrysteroReplicator(
{},
@@ -62,9 +105,13 @@ try {
}
replicator.allowReconnection();
await waitFor("a replacement relay WebSocket to open", () =>
Object.values(getRelaySockets()).some(
(socket) => !originalSockets.includes(socket) && socket.readyState === WebSocket.OPEN
await waitFor("a replacement relay WebSocket to be registered", () =>
Object.values(getRelaySockets()).some((socket) => !originalSockets.includes(socket))
);
const replacementSockets = Object.values(getRelaySockets()).filter((socket) => !originalSockets.includes(socket));
await Promise.all(
replacementSockets.map((socket, index) =>
waitForSocketOpen(socket, `replacement relay WebSocket ${index + 1} to open`)
)
);
+2 -5
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.0-beta.0-webapp",
"version": "1.0.0-beta.4-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
@@ -15,12 +15,9 @@
"octagonal-wheels": "^0.1.51"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"playwright": "^1.58.2",
"svelte": "5.56.3",
"typescript": "5.9.3",
"vite": "^8.0.16",
"vite-plugin-istanbul": "^9.0.1"
"vite": "^8.0.16"
}
}
-79
View File
@@ -1,79 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ---------------------------------------------------------------------------
// Load environment variables from .test.env (root) so that CouchDB
// connection details are visible to the test process.
// ---------------------------------------------------------------------------
function loadEnvFile(envPath: string): Record<string, string> {
const result: Record<string, string> = {};
if (!fs.existsSync(envPath)) return result;
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const val = trimmed.slice(eq + 1).trim();
result[key] = val;
}
return result;
}
// __dirname is src/apps/webapp — root is three levels up
const ROOT = path.resolve(__dirname, "../../..");
const envVars = {
...loadEnvFile(path.join(ROOT, ".env")),
...loadEnvFile(path.join(ROOT, ".test.env")),
};
// Make the loaded variables available to all test files via process.env.
for (const [k, v] of Object.entries(envVars)) {
if (!(k in process.env)) {
process.env[k] = v;
}
}
export default defineConfig({
testDir: "./test",
// Give each test plenty of time for replication round-trips.
timeout: 120_000,
expect: { timeout: 30_000 },
// Run test files sequentially; the tests themselves manage two contexts.
fullyParallel: false,
workers: 1,
reporter: "list",
use: {
baseURL: "http://localhost:3000",
// Use Chromium for OPFS and FileSystem API support.
...devices["Desktop Chrome"],
headless: true,
// Launch args to match the main vitest browser config.
launchOptions: {
args: ["--js-flags=--expose-gc"],
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
// Start the vite dev server before running the tests.
webServer: {
command: "npx vite --port 3000",
url: "http://localhost:3000",
// Re-use a running dev server when developing locally.
reuseExistingServer: !process.env.CI,
timeout: 30_000,
// Run from the webapp directory so vite finds its config.
cwd: __dirname,
},
});
@@ -10,6 +10,7 @@ import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
export interface FSAPIServiceModules extends ServiceModules {
vaultAccess: FileAccessFSAPI;
@@ -84,6 +85,7 @@ export function initialiseServiceModulesFSAPI(
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
// Rebuilder (platform-independent)
-238
View File
@@ -1,238 +0,0 @@
/**
* LiveSync WebApp E2E test entry point.
*
* When served by vite dev server (at /test.html), this module wires up
* `window.livesyncTest`, a plain JS API that Playwright tests can call via
* `page.evaluate()`. All methods are async and serialisation-safe.
*
* Vault storage is backed by OPFS so no `showDirectoryPicker()` interaction
* is required, making it fully headless-compatible.
*/
import { LiveSyncWebApp } from "./main";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
// --------------------------------------------------------------------------
// Internal state one app instance per page / browser context
// --------------------------------------------------------------------------
let app: LiveSyncWebApp | null = null;
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
/** Strip the "plain:" / "enc:" / … prefix used internally in PouchDB paths. */
function stripPrefix(raw: string): string {
return raw.replace(/^[^:]+:/, "");
}
interface TestCore {
serviceModules: {
databaseFileAccess: {
storeContent(path: FilePathWithPrefix, content: string): Promise<unknown>;
delete(path: FilePathWithPrefix): Promise<unknown>;
};
};
services?: {
replication?: {
databaseQueueCount?: { value: number };
storageApplyingCount?: { value: number };
replicate(showMessage: boolean): Promise<unknown>;
};
fileProcessing?: {
totalQueued?: { value: number };
batched?: { value: number };
processing?: { value: number };
};
database?: {
localDatabase: {
findAllNormalDocs(options?: { conflicts?: boolean }): AsyncIterable<{
_deleted?: boolean;
deleted?: boolean;
path?: string;
_rev?: string;
_conflicts?: string[];
size?: number;
mtime?: number;
}>;
};
};
};
}
/**
* Poll every 300 ms until all known processing queues are drained, or until
* the timeout elapses. Mirrors `waitForIdle` in the existing vitest harness.
*/
async function waitForIdle(core: TestCore, timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const q =
(core.services?.replication?.databaseQueueCount?.value ?? 0) +
(core.services?.fileProcessing?.totalQueued?.value ?? 0) +
(core.services?.fileProcessing?.batched?.value ?? 0) +
(core.services?.fileProcessing?.processing?.value ?? 0) +
(core.services?.replication?.storageApplyingCount?.value ?? 0);
if (q === 0) return;
await new Promise<void>((r) => compatGlobal.setTimeout(r, 300));
}
throw new Error(`waitForIdle timed out after ${timeoutMs} ms`);
}
function getCore(): TestCore {
const core = (app as unknown as { core: TestCore | null })?.core;
if (!core) throw new Error("Vault not initialised call livesyncTest.init() first");
return core;
}
// --------------------------------------------------------------------------
// Public test API
// --------------------------------------------------------------------------
export interface LiveSyncTestAPI {
/**
* Initialise a vault in OPFS under the given name and apply `settings`.
* Any previous contents of the OPFS directory are wiped first so each
* test run starts clean.
*/
init(vaultName: string, settings: Partial<ObsidianLiveSyncSettings>): Promise<void>;
/**
* Write `content` to the local PouchDB under `vaultPath` (equivalent to
* the CLI `put` command). Waiting for the DB write to finish is
* included; you still need to call `replicate()` to push to remote.
*/
putFile(vaultPath: string, content: string): Promise<boolean>;
/**
* Mark `vaultPath` as deleted in the local PouchDB (equivalent to CLI
* `rm`). Call `replicate()` afterwards to propagate to remote.
*/
deleteFile(vaultPath: string): Promise<boolean>;
/**
* Run one full replication cycle (push + pull) against the remote CouchDB,
* then wait for the local storage-application queue to drain.
*/
replicate(): Promise<boolean>;
/**
* Wait until all processing queues are idle. Usually not needed after
* `putFile` / `deleteFile` since those already await, but useful when
* testing results after `replicate()`.
*/
waitForIdle(timeoutMs?: number): Promise<void>;
/**
* Return metadata for `vaultPath` from the local database, or `null` if
* not found / deleted.
*/
getInfo(vaultPath: string): Promise<{
path: string;
revision: string;
conflicts: string[];
size: number;
mtime: number;
} | null>;
/** Convenience wrapper: returns true when the doc has ≥1 conflict revision. */
hasConflict(vaultPath: string): Promise<boolean>;
/** Tear down the current app instance. */
shutdown(): Promise<void>;
}
// --------------------------------------------------------------------------
// Implementation
// --------------------------------------------------------------------------
const livesyncTest: LiveSyncTestAPI = {
async init(vaultName: string, settings: Partial<ObsidianLiveSyncSettings>): Promise<void> {
// Clean up any stale OPFS data from previous runs.
const opfsRoot = await compatGlobal.navigator.storage.getDirectory();
try {
await opfsRoot.removeEntry(vaultName, { recursive: true });
} catch {
// directory did not exist that's fine
}
const vaultDir = await opfsRoot.getDirectoryHandle(vaultName, { create: true });
// Pre-write settings so they are loaded during initialise().
const livesyncDir = await vaultDir.getDirectoryHandle(".livesync", { create: true });
const settingsFile = await livesyncDir.getFileHandle("settings.json", { create: true });
const writable = await settingsFile.createWritable();
await writable.write(JSON.stringify(settings));
await writable.close();
app = new LiveSyncWebApp(vaultDir);
await app.initialize();
// Give background startup tasks a moment to settle.
await waitForIdle(getCore(), 30_000);
},
async putFile(vaultPath: string, content: string): Promise<boolean> {
const core = getCore();
const result = await core.serviceModules.databaseFileAccess.storeContent(
vaultPath as FilePathWithPrefix,
content
);
await waitForIdle(core);
return result !== false;
},
async deleteFile(vaultPath: string): Promise<boolean> {
const core = getCore();
const result = await core.serviceModules.databaseFileAccess.delete(vaultPath as FilePathWithPrefix);
await waitForIdle(core);
return result !== false;
},
async replicate(): Promise<boolean> {
const core = getCore();
const result = await core.services.replication.replicate(true);
// After replicate() resolves, remote docs may still be queued for
// local storage application wait until all queues are drained.
await waitForIdle(core);
return result !== false;
},
async waitForIdle(timeoutMs?: number): Promise<void> {
await waitForIdle(getCore(), timeoutMs ?? 60_000);
},
async getInfo(vaultPath: string) {
const core = getCore();
const db = core.services?.database;
for await (const doc of db.localDatabase.findAllNormalDocs({ conflicts: true })) {
if (doc._deleted || doc.deleted) continue;
const docPath = stripPrefix(doc.path ?? "");
if (docPath !== vaultPath) continue;
return {
path: docPath,
revision: doc._rev ?? "",
conflicts: doc._conflicts ?? [],
size: doc.size ?? 0,
mtime: doc.mtime ?? 0,
};
}
return null;
},
async hasConflict(vaultPath: string): Promise<boolean> {
const info = await livesyncTest.getInfo(vaultPath);
return (info?.conflicts?.length ?? 0) > 0;
},
async shutdown(): Promise<void> {
if (app) {
await app.shutdown();
app = null;
}
},
};
// Expose on window for Playwright page.evaluate() calls.
(compatGlobal as unknown as Record<string, unknown>).livesyncTest = livesyncTest;
-26
View File
@@ -1,26 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LiveSync WebApp E2E Test Page</title>
<style>
body {
font-family: monospace;
padding: 1rem;
}
#status {
margin-top: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<h2>LiveSync WebApp E2E</h2>
<p>This page is used by Playwright tests only. <code>window.livesyncTest</code> is exposed by the script below.</p>
<!-- status div required by LiveSyncWebApp internal helpers -->
<div id="status">Loading…</div>
<script type="module" src="/test-entry.ts"></script>
</body>
</html>
-292
View File
@@ -1,292 +0,0 @@
/**
* WebApp E2E tests two-vault scenarios.
*
* Each vault (A and B) runs in its own browser context so that JavaScript
* global state (including Trystero's global signalling tables) is fully
* isolated. The two vaults communicate only through the shared remote
* CouchDB database.
*
* Vault storage is OPFS-backed no file-picker interaction needed.
*
* Prerequisites:
* - A reachable CouchDB instance whose connection details are in .test.env
* (read automatically by playwright.config.ts).
*
* How to run:
* cd src/apps/webapp && npm run test:e2e
*/
import { test, expect, type BrowserContext, type Page, type TestInfo } from "@playwright/test";
import type { LiveSyncTestAPI } from "@/apps/webapp/test-entry";
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ---------------------------------------------------------------------------
// Settings helpers
// ---------------------------------------------------------------------------
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required env variable: ${name}`);
return v;
}
async function ensureCouchDbDatabase(uri: string, user: string, pass: string, dbName: string): Promise<void> {
const base = uri.replace(/\/+$/, "");
const dbUrl = `${base}/${encodeURIComponent(dbName)}`;
const auth = Buffer.from(`${user}:${pass}`, "utf-8").toString("base64");
const response = await fetch(dbUrl, {
method: "PUT",
headers: {
Authorization: `Basic ${auth}`,
},
});
// 201: created, 202: accepted, 412: already exists
if (response.status === 201 || response.status === 202 || response.status === 412) {
return;
}
const body = await response.text().catch(() => "");
throw new Error(`Failed to ensure CouchDB database (${response.status}): ${body}`);
}
function buildSettings(dbName: string): Record<string, unknown> {
return {
// Remote database (shared between A and B this is the replication target)
couchDB_URI: requireEnv("hostname").replace(/\/+$/, ""),
couchDB_USER: process.env["username"] ?? "",
couchDB_PASSWORD: process.env["password"] ?? "",
couchDB_DBNAME: dbName,
// Core behaviour
isConfigured: true,
liveSync: false,
syncOnSave: false,
syncOnStart: false,
periodicReplication: false,
gcDelay: 0,
savingDelay: 0,
notifyThresholdOfRemoteStorageSize: 0,
// Encryption off for test simplicity
encrypt: false,
// Disable plugin/hidden-file sync (not needed in webapp)
usePluginSync: false,
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
//Auto accept perr
P2P_AutoAcceptingPeers: "~.*",
};
}
// ---------------------------------------------------------------------------
// Test-page helpers
// ---------------------------------------------------------------------------
/** Navigate to the test entry page and wait for `window.livesyncTest`. */
async function openTestPage(ctx: BrowserContext): Promise<Page> {
const page = await ctx.newPage();
await page.goto("/test.html");
await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 });
return page;
}
/** Type-safe wrapper calls `window.livesyncTest.<method>(...args)` in the page. */
async function call<M extends keyof LiveSyncTestAPI>(
page: Page,
method: M,
...args: Parameters<LiveSyncTestAPI[M]>
): Promise<Awaited<ReturnType<LiveSyncTestAPI[M]>>> {
const invoke = () =>
page.evaluate(([m, a]) => (window as any).livesyncTest[m](...a), [method, args] as [
string,
unknown[],
]) as Promise<Awaited<ReturnType<LiveSyncTestAPI[M]>>>;
try {
return await invoke();
} catch (ex: any) {
const message = String(ex?.message ?? ex);
// Some startup flows may trigger one page reload; recover once.
if (
message.includes("Execution context was destroyed") ||
message.includes("Most likely the page has been closed")
) {
await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 });
return await invoke();
}
throw ex;
}
}
async function dumpCoverage(page: Page | undefined, label: string, testInfo: TestInfo): Promise<void> {
if (!process.env.PW_COVERAGE || !page || page.isClosed()) {
return;
}
const cov = await page
.evaluate(() => {
const data = (window as any).__coverage__;
if (!data) return null;
// Reset between tests to avoid runaway accumulation.
(window as any).__coverage__ = {};
return data;
})
.catch((): null => null);
if (!cov) return;
if (typeof cov === "object" && Object.keys(cov as Record<string, unknown>).length === 0) {
return;
}
const outDir = path.resolve(__dirname, "../.nyc_output");
fs.mkdirSync(outDir, { recursive: true });
const name = `${testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${label}.json`;
fs.writeFileSync(path.join(outDir, name), JSON.stringify(cov), "utf-8");
}
// ---------------------------------------------------------------------------
// Two-vault E2E suite
// ---------------------------------------------------------------------------
test.describe("WebApp two-vault E2E", () => {
let ctxA: BrowserContext;
let ctxB: BrowserContext;
let pageA: Page;
let pageB: Page;
const DB_SUFFIX = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const dbName = `${requireEnv("dbname")}-${DB_SUFFIX}`;
const settings = buildSettings(dbName);
test.beforeAll(async ({ browser }) => {
await ensureCouchDbDatabase(
String(settings.couchDB_URI ?? ""),
String(settings.couchDB_USER ?? ""),
String(settings.couchDB_PASSWORD ?? ""),
dbName
);
// Open Vault A and Vault B in completely separate browser contexts.
// Each context has its own JS runtime, IndexedDB and OPFS root, so
// Trystero global state and PouchDB instance names cannot collide.
ctxA = await browser.newContext();
ctxB = await browser.newContext();
pageA = await openTestPage(ctxA);
pageB = await openTestPage(ctxB);
await call(pageA, "init", "testvault_a", settings as any);
await call(pageB, "init", "testvault_b", settings as any);
});
test.afterAll(async () => {
await call(pageA, "shutdown").catch(() => {});
await call(pageB, "shutdown").catch(() => {});
await ctxA.close();
await ctxB.close();
});
test.afterEach(async ({}, testInfo) => {
await dumpCoverage(pageA, "vaultA", testInfo);
await dumpCoverage(pageB, "vaultB", testInfo);
});
// -----------------------------------------------------------------------
// Case 1: Vault A writes a file and can read its metadata back from the
// local database (no replication yet).
// -----------------------------------------------------------------------
test("Case 1: A writes a file and can get its info", async () => {
const FILE = "e2e/case1-a-only.md";
const CONTENT = "hello from vault A";
const ok = await call(pageA, "putFile", FILE, CONTENT);
expect(ok).toBe(true);
const info = await call(pageA, "getInfo", FILE);
expect(info).not.toBeNull();
expect(info!.path).toBe(FILE);
expect(info!.revision).toBeTruthy();
expect(info!.conflicts).toHaveLength(0);
});
// -----------------------------------------------------------------------
// Case 2: Vault A writes a file, both vaults replicate, and Vault B ends
// up with the file in its local database.
// -----------------------------------------------------------------------
test("Case 2: A writes a file, both replicate, B receives the file", async () => {
const FILE = "e2e/case2-sync.md";
const CONTENT = "content from A should appear in B";
await call(pageA, "putFile", FILE, CONTENT);
// A pushes to remote, B pulls from remote.
await call(pageA, "replicate");
await call(pageB, "replicate");
const infoB = await call(pageB, "getInfo", FILE);
expect(infoB).not.toBeNull();
expect(infoB!.path).toBe(FILE);
});
// -----------------------------------------------------------------------
// Case 3: Vault A deletes the file it synced in case 2. After both
// vaults replicate, Vault B no longer sees the file.
// -----------------------------------------------------------------------
test("Case 3: A deletes the file, both replicate, B no longer sees it", async () => {
// This test depends on Case 2 having put e2e/case2-sync.md into both vaults.
const FILE = "e2e/case2-sync.md";
await call(pageA, "deleteFile", FILE);
await call(pageA, "replicate");
await call(pageB, "replicate");
const infoB = await call(pageB, "getInfo", FILE);
// The file should be gone (null means not found or deleted).
expect(infoB).toBeNull();
});
// -----------------------------------------------------------------------
// Case 4: A and B each independently edit the same file that was already
// synced. After both vaults replicate the editing cycle, both
// vaults report a conflict on that file.
// -----------------------------------------------------------------------
test("Case 4: concurrent edits from A and B produce a conflict on both sides", async () => {
const FILE = "e2e/case4-conflict.md";
// 1) Write a baseline and synchronise so both vaults start from the
// same revision.
await call(pageA, "putFile", FILE, "base content");
await call(pageA, "replicate");
await call(pageB, "replicate");
// Confirm B has the base file with no conflicts yet.
const baseInfoB = await call(pageB, "getInfo", FILE);
expect(baseInfoB).not.toBeNull();
expect(baseInfoB!.conflicts).toHaveLength(0);
// 2) Both vaults write diverging content without syncing in between
// this creates two competing revisions.
await call(pageA, "putFile", FILE, "content from A (conflict side)");
await call(pageB, "putFile", FILE, "content from B (conflict side)");
// 3) Run replication on both sides. The order mirrors the pattern
// from the CLI two-vault tests (A → remote → B → remote → A).
await call(pageA, "replicate");
await call(pageB, "replicate");
await call(pageA, "replicate"); // re-check from A to pick up B's revision
// 4) At least one side must report a conflict.
const hasConflictA = await call(pageA, "hasConflict", FILE);
const hasConflictB = await call(pageB, "hasConflict", FILE);
expect(
hasConflictA || hasConflictB,
"Expected a conflict to appear on vault A or vault B after diverging edits"
).toBe(true);
});
});
+1 -31
View File
@@ -1,6 +1,5 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import istanbul from "vite-plugin-istanbul";
import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "../../..");
@@ -15,35 +14,9 @@ function readVersion(filePath: string): string | undefined {
const packageVersion = readVersion(path.resolve(repoRoot, "package.json"));
const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json"));
const enableCoverage = process.env.PW_COVERAGE === "1";
// https://vite.dev/config/
export default defineConfig({
plugins: [
svelte(),
...(enableCoverage
? [
istanbul({
cwd: repoRoot,
include: ["src/**/*.ts", "src/**/*.svelte"],
exclude: [
"node_modules",
"dist",
"test",
"coverage",
"src/apps/webapp/test/**",
"playwright.config.ts",
"vite.config.ts",
"**/*.spec.ts",
"**/*.test.ts",
],
extension: [".js", ".ts", ".svelte"],
requireEnv: false,
cypress: false,
checkProd: false,
}),
]
: []),
],
plugins: [svelte()],
resolve: {
alias: {
"@": path.resolve(__dirname, "../../"),
@@ -55,12 +28,9 @@ export default defineConfig({
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
// test.html is used by the Playwright dev-server; include it here
// so the production build doesn't emit warnings about unused inputs.
input: {
index: path.resolve(__dirname, "index.html"),
webapp: path.resolve(__dirname, "webapp.html"),
test: path.resolve(__dirname, "test.html"),
},
external: ["crypto"],
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.0-beta.0-webpeer",
"version": "1.0.0-beta.4-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
@@ -139,7 +139,7 @@ describe("database compatibility evaluation", () => {
});
});
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
it("compatibility: retains an earlier unstructured review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
@@ -159,7 +159,7 @@ describe("database compatibility evaluation", () => {
});
});
it("scopes the legacy marker to the Vault", () => {
it("compatibility: scopes the earlier review marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
});
});
@@ -0,0 +1,119 @@
/**
* Canonical English for LiveSync-owned messages whose wording is still being
* exercised. These keys remain application-owned and must not be added to
* Commonlib merely to make them available to the LiveSync translator.
*
* Move a message to the YAML catalogue when it is ready for translation, and
* 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.",
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.",
"Restart this device, then choose the source device when P2P Rebuild opens.":
"Restart this device, then choose the source device when P2P Rebuild opens.",
"Restart and Select Source Device": "Restart and Select Source Device",
"P2P Status pane": "P2P Status pane",
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited.":
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited.",
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery.":
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery.",
"Signalling relay URLs": "Signalling relay URLs",
"Peer discovery uses Nostr-compatible signalling relays.":
"Peer discovery uses Nostr-compatible signalling relays.",
"Use the project's public signalling relay": "Use the project's public signalling relay",
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.":
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay.",
"Learn more about P2P connections": "Learn more about P2P connections",
"Learn more about signalling and TURN": "Learn more about signalling and TURN",
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.":
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust.",
"Announce changes": "Announce changes",
"Announce changes automatically after connecting": "Announce changes automatically after connecting",
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.":
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection.",
"Stop announcing changes": "Stop announcing changes",
"Start announcing changes": "Start announcing changes",
"Follow changes": "Follow changes",
"Stop following changes from this device": "Stop following changes from this device",
"Follow changes from this device": "Follow changes from this device",
"Synchronise when this device connects": "Synchronise when this device connects",
"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",
"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}'?",
"This file has unresolved conflicts.": "This file has unresolved conflicts.",
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.":
"This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.",
"Sync now": "Sync now",
"Apply pending changes now": "Apply pending changes now",
"Copy database information for the active file": "Copy database information for the active file",
"Copy database information for a file": "Copy database information for a file",
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents.":
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents.",
"Choose file": "Choose file",
"Choose a file to inspect": "Choose a file to inspect",
"Database information for ${FILE}": "Database information for ${FILE}",
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.":
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.",
"Vault file: modified ${TIME}, size ${SIZE}": "Vault file: modified ${TIME}, size ${SIZE}",
"Vault file: missing": "Vault file: missing",
"Local database document: missing": "Local database document: missing",
"${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}",
"Winner revision": "Winner revision",
"Conflict revision": "Conflict revision",
"Unknown revision": "Unknown revision",
"Logical deletion": "Logical deletion",
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}":
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}",
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted":
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted",
"Matches the current Vault file": "Matches the current Vault file",
"Differs from the current Vault file": "Differs from the current Vault file",
"Retry reading revision": "Retry reading revision",
"Discard unreadable revision": "Discard unreadable revision",
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.":
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
"Revision metadata is unavailable on this device": "Revision metadata is unavailable on this device",
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.":
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.",
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.":
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.",
"Show revision history": "Show revision history",
"Use Vault file in local database": "Use Vault file in local database",
"Restore database winner to Vault": "Restore database winner to Vault",
"Copy database information": "Copy database information",
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.",
"Recreate current chunks": "Recreate current chunks",
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
"Verify and repair all files": "Verify and repair all files",
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.":
"Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.",
"Verify all": "Verify all",
"Connection settings": "Connection settings",
"Saved connections": "Saved connections",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
+16 -16
View File
@@ -8810,15 +8810,15 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "你正在将此设备加入已有同步配置。",
},
"Ui.SetupWizard.SelectExisting.ManualOption": {
def: "Enter the server information manually",
def: "Configure a remote manually",
zh: "手动输入服务器信息",
},
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": {
def: "Configure the same server information as your other devices again manually. This is intended only for advanced users.",
def: "Configure the same remote as your other devices again manually. This is intended only for advanced users.",
zh: "手动重新配置与你其他设备相同的服务器信息。此方式仅适用于高级用户。",
},
"Ui.SetupWizard.SelectExisting.ProceedManual": {
def: "I know my server details, let me enter them",
def: "Proceed with manual configuration",
zh: "我知道服务器信息,让我手动输入",
},
"Ui.SetupWizard.SelectExisting.ProceedQr": {
@@ -8854,19 +8854,19 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "设备设置方式",
},
"Ui.SetupWizard.SelectNew.Guidance": {
def: "We will now proceed with the server configuration.",
def: "We will now configure the synchronisation connection.",
zh: "接下来将继续配置服务器连接信息。",
},
"Ui.SetupWizard.SelectNew.ManualOption": {
def: "Enter the server information manually",
def: "Configure a remote manually",
zh: "手动输入服务器信息",
},
"Ui.SetupWizard.SelectNew.ManualOptionDesc": {
def: "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings.",
def: "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings. You can also use it for P2P synchronisation instead of CouchDB or S3-compatible Object Storage.",
zh: "如果你没有 Setup URI,或希望自行配置更详细的参数,可选择此高级选项。",
},
"Ui.SetupWizard.SelectNew.ProceedManual": {
def: "I know my server details, let me enter them",
def: "Proceed with manual configuration",
zh: "我知道服务器信息,让我手动输入",
},
"Ui.SetupWizard.SelectNew.ProceedSetupUri": {
@@ -8874,7 +8874,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "使用 Setup URI 继续",
},
"Ui.SetupWizard.SelectNew.Question": {
def: "How would you like to configure the connection to your server?",
def: "How would you like to configure this synchronisation connection?",
zh: "你希望如何配置服务器连接?",
},
"Ui.SetupWizard.SelectNew.SetupUriOption": {
@@ -8882,7 +8882,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "使用 Setup URI(推荐)",
},
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": {
def: "A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method.",
def: "A Setup URI is a single string containing connection and authentication details. When one is available from a setup script, it provides a simple and secure configuration method.",
zh: "Setup URI 是一段包含服务器地址和认证信息的文本。如果你的服务器安装脚本已经生成了它,这是最简单且安全的配置方式。",
},
"Ui.SetupWizard.SelectNew.Title": {
@@ -8890,11 +8890,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "连接方式",
},
"Ui.SetupWizard.SetupRemote.BucketOption": {
def: "S3/MinIO/R2 Object Storage",
def: "S3-compatible Object Storage",
zh: "S3/MinIO/R2 对象存储",
},
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": {
def: "Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up.",
def: "Synchronisation using journal files. You must already have an S3-compatible Object Storage service set up, such as Amazon S3, MinIO, or Cloudflare R2.",
zh: "使用日志文件进行同步。你需要先准备好兼容 S3/MinIO/R2 的对象存储服务。",
},
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": {
@@ -8902,11 +8902,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "这是当前设计下最适合的同步方式,所有功能都可用。你需要先准备好 CouchDB 实例。",
},
"Ui.SetupWizard.SetupRemote.Guidance": {
def: "Please select the type of server you are connecting to.",
def: "Select the remote type for this synchronisation setup.",
zh: "请选择你要连接的服务器类型。",
},
"Ui.SetupWizard.SetupRemote.P2POption": {
def: "Peer-to-Peer only",
def: "Peer-to-Peer (P2P)",
zh: "仅点对点",
},
"Ui.SetupWizard.SetupRemote.P2POptionDesc": {
@@ -8914,7 +8914,7 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "启用设备之间的直接同步。无需服务器,但两台设备必须同时在线,且部分功能可能受限。互联网连接仅用于信令,不用于传输数据。",
},
"Ui.SetupWizard.SetupRemote.ProceedBucket": {
def: "Continue to S3/MinIO/R2 setup",
def: "Continue to Object Storage setup",
zh: "继续配置 S3/MinIO/R2",
},
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": {
@@ -8922,11 +8922,11 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
zh: "继续配置 CouchDB",
},
"Ui.SetupWizard.SetupRemote.ProceedP2P": {
def: "Continue to Peer-to-Peer only setup",
def: "Continue to P2P setup",
zh: "继续配置仅点对点模式",
},
"Ui.SetupWizard.SetupRemote.Title": {
def: "Enter Server Information",
def: "Choose a synchronisation remote",
zh: "输入服务器信息",
},
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": {
+16 -16
View File
@@ -1074,9 +1074,9 @@
"Ui.SetupWizard.RebuildEverythingP2P.Proceed": "I Understand, Prepare This Device",
"Ui.SetupWizard.RebuildEverythingP2P.Title": "Final Confirmation: Prepare This Device for P2P",
"Ui.SetupWizard.SelectExisting.Guidance": "You are adding this device to an existing synchronisation setup.",
"Ui.SetupWizard.SelectExisting.ManualOption": "Enter the server information manually",
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": "Configure the same server information as your other devices again manually. This is intended only for advanced users.",
"Ui.SetupWizard.SelectExisting.ProceedManual": "I know my server details, let me enter them",
"Ui.SetupWizard.SelectExisting.ManualOption": "Configure a remote manually",
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": "Configure the same remote as your other devices again manually. This is intended only for advanced users.",
"Ui.SetupWizard.SelectExisting.ProceedManual": "Proceed with manual configuration",
"Ui.SetupWizard.SelectExisting.ProceedQr": "Scan the QR code displayed on an active device using this device's camera.",
"Ui.SetupWizard.SelectExisting.ProceedSetupUri": "Proceed with Setup URI",
"Ui.SetupWizard.SelectExisting.QrOption": "Scan a QR Code (Recommended for mobile)",
@@ -1085,25 +1085,25 @@
"Ui.SetupWizard.SelectExisting.SetupUriOption": "Use a Setup URI (Recommended)",
"Ui.SetupWizard.SelectExisting.SetupUriOptionDesc": "Paste the Setup URI generated from one of your active devices.",
"Ui.SetupWizard.SelectExisting.Title": "Device Setup Method",
"Ui.SetupWizard.SelectNew.Guidance": "We will now proceed with the server configuration.",
"Ui.SetupWizard.SelectNew.ManualOption": "Enter the server information manually",
"Ui.SetupWizard.SelectNew.ManualOptionDesc": "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings.",
"Ui.SetupWizard.SelectNew.ProceedManual": "I know my server details, let me enter them",
"Ui.SetupWizard.SelectNew.Guidance": "We will now configure the synchronisation connection.",
"Ui.SetupWizard.SelectNew.ManualOption": "Configure a remote manually",
"Ui.SetupWizard.SelectNew.ManualOptionDesc": "This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings. You can also use it for P2P synchronisation instead of CouchDB or S3-compatible Object Storage.",
"Ui.SetupWizard.SelectNew.ProceedManual": "Proceed with manual configuration",
"Ui.SetupWizard.SelectNew.ProceedSetupUri": "Proceed with Setup URI",
"Ui.SetupWizard.SelectNew.Question": "How would you like to configure the connection to your server?",
"Ui.SetupWizard.SelectNew.Question": "How would you like to configure this synchronisation connection?",
"Ui.SetupWizard.SelectNew.SetupUriOption": "Use a Setup URI (Recommended)",
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": "A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method.",
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": "A Setup URI is a single string containing connection and authentication details. When one is available from a setup script, it provides a simple and secure configuration method.",
"Ui.SetupWizard.SelectNew.Title": "Connection Method",
"Ui.SetupWizard.SetupRemote.BucketOption": "S3/MinIO/R2 Object Storage",
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": "Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up.",
"Ui.SetupWizard.SetupRemote.BucketOption": "S3-compatible Object Storage",
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": "Synchronisation using journal files. You must already have an S3-compatible Object Storage service set up, such as Amazon S3, MinIO, or Cloudflare R2.",
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": "This is the most suitable synchronisation method for the current design. All features are available. You must already have a CouchDB instance set up.",
"Ui.SetupWizard.SetupRemote.Guidance": "Please select the type of server you are connecting to.",
"Ui.SetupWizard.SetupRemote.P2POption": "Peer-to-Peer only",
"Ui.SetupWizard.SetupRemote.Guidance": "Select the remote type for this synchronisation setup.",
"Ui.SetupWizard.SetupRemote.P2POption": "Peer-to-Peer (P2P)",
"Ui.SetupWizard.SetupRemote.P2POptionDesc": "This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.",
"Ui.SetupWizard.SetupRemote.ProceedBucket": "Continue to S3/MinIO/R2 setup",
"Ui.SetupWizard.SetupRemote.ProceedBucket": "Continue to Object Storage setup",
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": "Continue to CouchDB setup",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to Peer-to-Peer only setup",
"Ui.SetupWizard.SetupRemote.Title": "Enter Server Information",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to P2P setup",
"Ui.SetupWizard.SetupRemote.Title": "Choose a synchronisation remote",
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
"Use a custom passphrase": "Use a custom passphrase",
"Use a Setup URI (Recommended)": "Use a Setup URI (Recommended)",
+30 -30
View File
@@ -934,25 +934,25 @@ P2P:
NoKnownPeers: No peers has been detected, waiting incoming other peers...
Note:
description: >2-
This replicator allows us to synchronise our vault with other devices
using a peer-to-peer connection. We can use this to synchronise our vault
with our other devices without using a cloud service.
This replicator allows us to synchronise our vault with other devices
using a peer-to-peer connection. We can use this to synchronise our vault
with our other devices without using a cloud service.
This replicator is based on Trystero. It also uses a signalling server to
establish a connection between devices. The signalling server is used to
exchange connection information between devices. It does (or,should) not
know or store any of our data.
This replicator is based on Trystero. It also uses a signalling server to
establish a connection between devices. The signalling server is used to
exchange connection information between devices. It does (or,should) not
know or store any of our data.
The signalling server can be hosted by anyone. This is just a Nostr relay.
For the sake of simplicity and checking the behaviour of the replicator,
an instance of the signalling server is hosted by vrtmrz. You can use the
experimental server provided by vrtmrz, or you can use any other server.
The signalling server can be hosted by anyone. This is just a Nostr relay.
For the sake of simplicity and checking the behaviour of the replicator,
an instance of the signalling server is hosted by vrtmrz. You can use the
experimental server provided by vrtmrz, or you can use any other server.
By the way, even if the signalling server does not store our data, it can
see the connection information of some of our devices. Please be aware of
this. Also, be cautious when using the server provided by someone else.
By the way, even if the signalling server does not store our data, it can
see the connection information of some of our devices. Please be aware of
this. Also, be cautious when using the server provided by someone else.
important_note: Peer-to-Peer Replicator.
important_note_sub: This feature is still on the bleeding edge. Please be aware
that ensure your data is backed up before using this feature. And, we
@@ -1895,9 +1895,9 @@ Ui:
Guidance: We will now guide you through a few questions to simplify the synchronisation setup.
SelectExisting:
Guidance: You are adding this device to an existing synchronisation setup.
ManualOption: Enter the server information manually
ManualOptionDesc: Configure the same server information as your other devices again manually. This is intended only for advanced users.
ProceedManual: I know my server details, let me enter them
ManualOption: Configure a remote manually
ManualOptionDesc: Configure the same remote as your other devices again manually. This is intended only for advanced users.
ProceedManual: Proceed with manual configuration
ProceedQr: Scan the QR code displayed on an active device using this device's camera.
ProceedSetupUri: Proceed with Setup URI
Question: Please select a method to import the settings from another device.
@@ -1907,14 +1907,14 @@ Ui:
SetupUriOptionDesc: Paste the Setup URI generated from one of your active devices.
Title: Device Setup Method
SelectNew:
Guidance: We will now proceed with the server configuration.
ManualOption: Enter the server information manually
ManualOptionDesc: This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings.
ProceedManual: I know my server details, let me enter them
Guidance: We will now configure the synchronisation connection.
ManualOption: Configure a remote manually
ManualOptionDesc: This is an advanced option for users who do not have a Setup URI or who want to configure detailed settings. You can also use it for P2P synchronisation instead of CouchDB or S3-compatible Object Storage.
ProceedManual: Proceed with manual configuration
ProceedSetupUri: Proceed with Setup URI
Question: How would you like to configure the connection to your server?
Question: How would you like to configure this synchronisation connection?
SetupUriOption: Use a Setup URI (Recommended)
SetupUriOptionDesc: A Setup URI is a single string containing your server address and authentication details. If one was generated by your server installation script, it provides a simple and secure configuration method.
SetupUriOptionDesc: A Setup URI is a single string containing connection and authentication details. When one is available from a setup script, it provides a simple and secure configuration method.
Title: Connection Method
OutroAskUserMode:
CompatibleOption: The remote is already set up, and the configuration is compatible (or became compatible through this operation).
@@ -1951,13 +1951,13 @@ Ui:
Proceed: I Understand, Prepare This Device
Title: "Final Confirmation: Prepare This Device for P2P"
SetupRemote:
BucketOption: S3/MinIO/R2 Object Storage
BucketOptionDesc: Synchronisation using journal files. You must already have an S3/MinIO/R2 compatible object storage service set up.
BucketOption: S3-compatible Object Storage
BucketOptionDesc: Synchronisation using journal files. You must already have an S3-compatible Object Storage service set up, such as Amazon S3, MinIO, or Cloudflare R2.
CouchDbOptionDesc: This is the most suitable synchronisation method for the current design. All features are available. You must already have a CouchDB instance set up.
Guidance: Please select the type of server you are connecting to.
P2POption: Peer-to-Peer only
Guidance: Select the remote type for this synchronisation setup.
P2POption: Peer-to-Peer (P2P)
P2POptionDesc: This enables direct synchronisation between devices. No server is required, but both devices must be online at the same time and some features may be limited. Internet connectivity is required only for signalling, not for data transfer.
ProceedBucket: Continue to S3/MinIO/R2 setup
ProceedBucket: Continue to Object Storage setup
ProceedCouchDb: Continue to CouchDB setup
ProceedP2P: Continue to Peer-to-Peer only setup
Title: Enter Server Information
ProceedP2P: Continue to P2P setup
Title: Choose a synchronisation remote
+7 -1
View File
@@ -65,7 +65,9 @@ export type I18N_LANGS =
export type MESSAGE = { [key in I18N_LANGS]?: string };
import { Logger } from "octagonal-wheels/common/logger";
import type { CommonlibMessageKey } from "@vrtmrz/livesync-commonlib/context";
import type { MessageKeys } from "./messages/combinedMessages.dev.ts";
import type { LiveSyncProvisionalMessageKey } from "./messages/LiveSyncProvisionalMessages.ts";
export function expandKeywords<T extends Record<string, U>, U extends Record<string, string>>(
message: T,
@@ -114,4 +116,8 @@ export function expandKeywords<T extends Record<string, U>, U extends Record<str
return ret as T;
}
export type AllMessageKeys = MessageKeys;
/** Keys translated by LiveSync itself, including English-only provisional messages. */
export type LiveSyncCatalogueMessageKey = MessageKeys | LiveSyncProvisionalMessageKey;
/** Keys accepted by the composed LiveSync and Commonlib translation boundary. */
export type AllMessageKeys = LiveSyncCatalogueMessageKey | CommonlibMessageKey;
+25 -7
View File
@@ -1,10 +1,19 @@
// Avoid using Obsidian's native function for CLIs.
import { getLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { englishMessageTranslator, type MessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import {
commonlibEnglishMessages,
englishMessageTranslator,
type CommonlibMessageKey,
type MessageTranslator,
} from "@vrtmrz/livesync-commonlib/context";
import { LOG_KIND_WARNING, notice } from "octagonal-wheels/common/logger";
import type { TaggedType } from "octagonal-wheels/common/types";
import type { AllMessageKeys, I18N_LANGS } from "./rosetta";
import type { AllMessageKeys, I18N_LANGS, LiveSyncCatalogueMessageKey } from "./rosetta";
import { allMessages } from "./messages/combinedMessages.prod.ts";
import {
liveSyncProvisionalEnglishMessages,
type LiveSyncProvisionalMessageKey,
} from "./messages/LiveSyncProvisionalMessages.ts";
const obsidianLangMap: Record<string, I18N_LANGS> = {
de: "de",
@@ -58,7 +67,10 @@ export function setLang(lang: I18N_LANGS) {
function _getMessage(key: string, lang: I18N_LANGS) {
if (key.trim() == "") return key;
const msgs = allMessages[key] ?? undefined;
const provisionalEnglish = liveSyncProvisionalEnglishMessages[key as LiveSyncProvisionalMessageKey];
const msgs =
allMessages[key] ?? (provisionalEnglish === undefined ? undefined : ({ def: provisionalEnglish } as const));
if (msgs === undefined && isCommonlibMessageKey(key)) return englishMessageTranslator(key);
const resolvedLang = resolveLanguage(lang);
let msg = msgs?.[resolvedLang];
@@ -87,12 +99,16 @@ export function $t(message: string, lang?: I18N_LANGS) {
}
export function translateIfAvailable(message: string, lang?: I18N_LANGS) {
if (message.trim() == "" || allMessages[message] === undefined) return message;
if (message.trim() == "" || (!isLiveSyncMessageKey(message) && !isCommonlibMessageKey(message))) return message;
return $t(message, lang);
}
function isLiveSyncMessageKey(key: string): key is AllMessageKeys {
return key in allMessages;
function isCommonlibMessageKey(key: string): key is CommonlibMessageKey {
return key in commonlibEnglishMessages;
}
function isLiveSyncMessageKey(key: string): key is LiveSyncCatalogueMessageKey {
return key in allMessages || key in liveSyncProvisionalEnglishMessages;
}
/**
@@ -124,7 +140,9 @@ export function $msg<T extends AllMessageKeys>(
}
/** Supplies the LiveSync-owned language catalogue through the Commonlib host boundary. */
export const translateLiveSyncMessage: MessageTranslator = (key, params) => {
export type LiveSyncMessageTranslator = MessageTranslator<AllMessageKeys>;
export const translateLiveSyncMessage: LiveSyncMessageTranslator = (key, params) => {
if (!isLiveSyncMessageKey(key)) return englishMessageTranslator(key, params);
return $msg(key, params === undefined ? undefined : { ...params });
};
+31
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { englishMessageTranslator } from "@vrtmrz/livesync-commonlib/context";
import { $msg, setLang, translateLiveSyncMessage } from "@/common/translation";
import { SUPPORTED_I18N_LANGS } from "@/common/rosetta";
import { liveSyncProvisionalEnglishMessages } from "@/common/messages/LiveSyncProvisionalMessages";
describe("LiveSync-owned translation catalogue", () => {
afterEach(() => setLang("def"));
@@ -25,4 +26,34 @@ describe("LiveSync-owned translation catalogue", () => {
expect(translateLiveSyncMessage("Active Remote Type")).toBe(englishMessageTranslator("Active Remote Type"));
});
it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => {
expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts.");
expect($msg("More actions for ${DEVICE}", { DEVICE: "phone" })).toBe("More actions for phone");
expect($msg("Connection settings")).toBe("Connection settings");
expect($msg("Saved connections")).toBe("Saved connections");
expect(
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: "3",
})
).toBe("This file has 3 unresolved versions. They will be reviewed one pair at a time.");
expect(translateLiveSyncMessage("This file has unresolved conflicts.")).toBe(
"This file has unresolved conflicts."
);
});
it("keeps the additional-device P2P Fetch explanation in the LiveSync-owned provisional catalogue", () => {
expect(liveSyncProvisionalEnglishMessages).toMatchObject({
"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.",
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.":
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.",
"Restart this device, then choose the source device when P2P Rebuild opens.":
"Restart this device, then choose the source device when P2P Rebuild opens.",
"Restart and Select Source Device": "Restart and Select Source Device",
"P2P Status pane": "P2P Status pane",
});
});
});
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
Notice: class Notice {},
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { services: unknown };
get services() {
return this.core.services;
}
},
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
EVEN: Symbol("even"),
disposeMemoObject: vi.fn(),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
memoIfNotExist: vi.fn(),
memoObject: vi.fn(),
retrieveMemoObject: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { ConfigSync } from "./CmdConfigSync";
describe("ConfigSync commands", () => {
it("shows the Customisation Sync command only whilst the feature is enabled", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
usePluginSync: false,
};
const showPluginSyncModal = vi.fn();
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
settings,
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
},
},
addRibbonIcon: vi.fn(() => ({
addClass: vi.fn(),
})),
showPluginSyncModal,
});
configSync.onload();
const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex");
expect(command?.checkCallback?.(true)).toBe(false);
settings.usePluginSync = true;
expect(command?.checkCallback?.(true)).toBe(true);
expect(command?.checkCallback?.(false)).toBe(true);
expect(showPluginSyncModal).toHaveBeenCalledOnce();
});
});
+8 -2
View File
@@ -454,8 +454,14 @@ export class ConfigSync extends LiveSyncCommands {
this.services.API.addCommand({
id: "livesync-plugin-dialog-ex",
name: "Show customization sync dialog",
callback: () => {
this.showPluginSyncModal();
checkCallback: (checking) => {
if (!this.isThisModuleEnabled()) {
return false;
}
if (!checking) {
this.showPluginSyncModal();
}
return true;
},
});
this.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => {
@@ -54,11 +54,17 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
type HiddenFileInitialisationProgress = {
log(message: string): void;
once(message: string): void;
done(message?: string): void;
};
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
@@ -100,37 +106,45 @@ export class HiddenFileSync extends LiveSyncCommands {
this.services.API.addCommand({
id: "livesync-sync-internal",
name: "(re)initialise hidden files between storage and database",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.initialiseInternalFileSync("safe", true);
}
return true;
},
});
this.services.API.addCommand({
id: "livesync-scaninternal-storage",
name: "Scan hidden file changes on the storage",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.scanAllStorageChanges(true);
}
return true;
},
});
this.services.API.addCommand({
id: "livesync-scaninternal-database",
name: "Scan hidden file changes on the local database",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.scanAllDatabaseChanges(true);
}
return true;
},
});
this.services.API.addCommand({
id: "livesync-internal-scan-offline-changes",
name: "Scan and apply all offline hidden-file changes",
callback: () => {
if (this.isReady()) {
checkCallback: (checking) => {
if (!this.isManualCommandAvailable()) return false;
if (!checking) {
void this.applyOfflineChanges(true);
}
return true;
},
});
eventHub.onEvent(EVENT_SETTING_SAVED, () => {
@@ -182,12 +196,16 @@ export class HiddenFileSync extends LiveSyncCommands {
}
isReady() {
if (!this._isMainReady) return false;
if (!this._isMainReady()) return false;
if (this._isMainSuspended()) return false;
if (!this.isThisModuleEnabled()) return false;
return true;
}
private isManualCommandAvailable() {
return this.settings.useAdvancedMode && this.isReady() && this._isDatabaseReady();
}
async performStartupScan(showNotice: boolean) {
await this.applyOfflineChanges(showNotice);
}
@@ -1421,13 +1439,18 @@ Offline Changed files: ${files.length}`;
direction: SyncDirection,
showMessage: boolean,
// filesAll: InternalFileInfo[] | false = false,
targetFilesSrc: string[] | false = false
targetFilesSrc: string[] | false = false,
initialisationProgress?: HiddenFileInitialisationProgress
) {
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
const p = this._progress("[⚙ Initialise]\n", logLevel);
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
// p.log("Resolving conflicts before starting...");
// await this.resolveConflictOnInternalFiles();
p.log("Initialising hidden files sync...");
// The initialisation progress owns the user-visible Notice. Its child
// rebuild and scan operations still write ordinary log entries, but
// must not each create another keep-alive Notice.
const showChildNotices = false;
// TODO: Handling ignore files cannot be performed to the hidden files.
const targetFiles = targetFilesSrc
@@ -1436,35 +1459,38 @@ Offline Changed files: ${files.length}`;
if (direction == "pushForce" || direction == "push") {
const onlyNew = direction == "push";
p.log(`Started: Storage --> Database ${onlyNew ? "(Only New)" : ""}`);
const updatedFiles = await this.rebuildFromStorage(showMessage, targetFiles, onlyNew);
const updatedFiles = await this.rebuildFromStorage(showChildNotices, targetFiles, onlyNew);
// making doubly sure, No more losing files.
// I did so many times during the development.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
await this.scanAllStorageChanges(showMessage, true, false);
await this.scanAllDatabaseChanges(showMessage, true, false);
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
if (direction == "pullForce" || direction == "pull") {
const onlyNew = direction == "pull";
p.log(`Started: Database --> Storage ${onlyNew ? "(Only New)" : ""}`);
const updatedEntries = await this.rebuildFromDatabase(showMessage, targetFiles, onlyNew);
const updatedEntries = await this.rebuildFromDatabase(showChildNotices, targetFiles, onlyNew);
const updatedFiles = updatedEntries.map((e) => stripAllPrefixes(this.getPath(e)));
// making doubly sure, No more losing files.
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
await this.scanAllDatabaseChanges(showMessage, true, false);
await this.scanAllStorageChanges(showMessage, true, false);
p.log("Checking for remaining database and storage changes...");
await this.scanAllDatabaseChanges(showChildNotices, true, false);
await this.scanAllStorageChanges(showChildNotices, true, false);
}
if (direction == "safe") {
p.log(`Started: Database <--> Storage (by modified date)`);
const updatedFiles = await this.rebuildMerging(showMessage, targetFiles);
const updatedFiles = await this.rebuildMerging(showChildNotices, targetFiles);
await this.adoptCurrentStorageFilesAsProcessed(updatedFiles);
await this.adoptCurrentDatabaseFilesAsProcessed(updatedFiles);
// And, scan other changes on the database (i.e. files which are on only other devices)
await this.scanAllStorageChanges(showMessage, true, false);
await this.scanAllDatabaseChanges(showMessage, true, false);
p.log("Checking for remaining storage and database changes...");
await this.scanAllStorageChanges(showChildNotices, true, false);
await this.scanAllDatabaseChanges(showChildNotices, true, false);
}
p.done();
}
@@ -1789,32 +1815,44 @@ Offline Changed files: ${files.length}`;
}
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
const result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
enable: async () => {
this._log("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true);
},
});
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
let result: ConfigureHiddenFileSyncResult;
try {
result = await configureHiddenFileSyncMode(mode, {
disable: async () => {
// await this.core.$allSuspendExtraSync();
await this.core.services.setting.applyPartial(
{
syncInternalFiles: false,
},
true
);
// this.core.settings.syncInternalFiles = false;
// await this.core.saveSettings();
},
enable: async () => {
// Open the one user-visible progress Notice before saving
// the setting. Large Vaults can otherwise appear idle
// before the initial file enumeration begins.
initialisationProgress = this._progress("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
initialisationProgress.log("Preparing Hidden File Sync...");
await this.core.services.setting.applyPartial(
{
useAdvancedMode: true,
syncInternalFiles: true,
},
true
);
},
initialise: async (direction) => {
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
initialisationProgress = undefined;
},
});
} catch (error) {
initialisationProgress?.done("Failed");
throw error;
}
if (result == "ignored" || result == "disabled") {
return;
}
@@ -1822,7 +1860,7 @@ Offline Changed files: ${files.length}`;
// this.plugin.settings.syncInternalFiles = true;
// await this.plugin.saveSettings();
this._log(`Done! Restarting the app is strongly recommended!`, LOG_LEVEL_NOTICE);
this._log("Hidden File Sync initialisation completed.", LOG_LEVEL_INFO);
}
// <-- Configuration handling
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
@@ -7,13 +8,16 @@ vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
plugin!: { app: unknown };
core!: { services: unknown };
core!: { services: unknown; settings: unknown };
get app() {
return this.plugin.app;
}
get services() {
return this.core.services;
}
get settings() {
return this.core.settings;
}
},
}));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
@@ -21,8 +25,69 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
}));
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
describe("HiddenFileSync configuration-change notices", () => {
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings = {
syncInternalFiles: false,
useAdvancedMode: false,
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings,
services: {
API: {
addCommand: vi.fn((command) => commands.push(command)),
},
},
},
_isMainReady: vi.fn(() => true),
_isMainSuspended: vi.fn(() => false),
_isDatabaseReady: vi.fn(() => true),
});
hiddenFileSync.onload();
const commandIds = [
"livesync-sync-internal",
"livesync-scaninternal-storage",
"livesync-scaninternal-database",
"livesync-internal-scan-offline-changes",
];
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(false);
}
settings.syncInternalFiles = true;
settings.useAdvancedMode = true;
for (const commandId of commandIds) {
const command = commands.find(({ id }) => id === commandId);
expect(command?.checkCallback?.(true)).toBe(true);
}
});
it("does not report Hidden File Sync as ready before the main runtime is ready", () => {
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
settings: {
syncInternalFiles: true,
},
},
_isMainReady: vi.fn(() => false),
_isMainSuspended: vi.fn(() => false),
});
expect(hiddenFileSync.isReady()).toBe(false);
});
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
const noticeGroups = {
setItem: vi.fn(),
@@ -102,4 +167,106 @@ describe("HiddenFileSync configuration-change notices", () => {
expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce();
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
});
it("keeps subordinate initialisation phases below Notice level so one progress Notice owns the scan", async () => {
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const rebuildMerging = vi.fn(async () => []);
const adoptCurrentStorageFilesAsProcessed = vi.fn(async () => undefined);
const adoptCurrentDatabaseFilesAsProcessed = vi.fn(async () => undefined);
const scanAllStorageChanges = vi.fn(async () => undefined);
const scanAllDatabaseChanges = vi.fn(async () => undefined);
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
_progress: vi.fn(() => progress),
rebuildMerging,
adoptCurrentStorageFilesAsProcessed,
adoptCurrentDatabaseFilesAsProcessed,
scanAllStorageChanges,
scanAllDatabaseChanges,
});
await hiddenFileSync.initialiseInternalFileSync("safe", true);
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
expect(progress.done).toHaveBeenCalledOnce();
});
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
await handlers.initialise("safe");
return "enabled";
});
const events: string[] = [];
const progress = {
log: vi.fn((message: string) => {
events.push(`progress:${message}`);
}),
once: vi.fn(),
done: vi.fn(),
};
const createProgress = vi.fn(() => progress);
const applyPartial = vi.fn(async () => {
events.push("apply-settings");
});
const initialiseInternalFileSync = vi.fn(async () => undefined);
const log = vi.fn();
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
setting: { applyPartial },
},
},
initialiseInternalFileSync,
_progress: createProgress,
_log: log,
});
await hiddenFileSync.configureHiddenFileSync("MERGE");
expect(createProgress).toHaveBeenCalledWith("[⚙ Initialise]\n", LOG_LEVEL_NOTICE);
expect(events[0]).toBe("progress:Preparing Hidden File Sync...");
expect(initialiseInternalFileSync).toHaveBeenCalledWith("safe", true, false, progress);
expect(log).not.toHaveBeenCalledWith("Gathering files for enabling Hidden File Sync", LOG_LEVEL_NOTICE);
expect(log).not.toHaveBeenCalledWith("Done! Restarting the app is strongly recommended!", LOG_LEVEL_NOTICE);
expect(log).toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
});
it("closes the preparation Notice when enabling Hidden File Sync fails", async () => {
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
await handlers.enable();
return "enabled";
});
const error = new Error("setting persistence failed");
const progress = {
log: vi.fn(),
once: vi.fn(),
done: vi.fn(),
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
core: {
services: {
setting: {
applyPartial: vi.fn(async () => {
throw error;
}),
},
},
},
_progress: vi.fn(() => progress),
_log: vi.fn(),
});
await expect(hiddenFileSync.configureHiddenFileSync("MERGE")).rejects.toBe(error);
expect(progress.done).toHaveBeenCalledWith("Failed");
});
});
@@ -0,0 +1,257 @@
import { describe, expect, it, vi } from "vitest";
import PouchDB from "pouchdb-core";
import HttpPouch from "pouchdb-adapter-http";
import MemoryAdapter from "pouchdb-adapter-memory";
import replication from "pouchdb-replication";
vi.mock("@/features/LiveSyncCommands", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { settings: unknown };
get settings() {
return this.core.settings;
}
},
}));
vi.mock("@/common/events", () => ({
EVENT_ANALYSE_DB_USAGE: "analyse",
EVENT_REQUEST_PERFORM_GC_V3: "gc",
eventHub: {
onEvent: vi.fn(),
},
}));
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
type DocumentID,
type EntryDoc,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
PouchDB.plugin(HttpPouch).plugin(MemoryAdapter).plugin(replication);
type FixtureContent = {
type: "leaf" | "plain";
data?: string;
path?: string;
children?: string[];
ctime?: number;
mtime?: number;
size?: number;
eden?: Record<string, never>;
};
type FixtureDocument = PouchDB.Core.PutDocument<FixtureContent> & {
_id: DocumentID;
_revisions?: {
start: number;
ids: string[];
};
};
function chunk(id: string, data = id): FixtureDocument {
return {
_id: id as DocumentID,
type: "leaf",
data,
};
}
function revision(id: string, rev: string, history: string[], children: string[]): FixtureDocument {
return {
_id: id as DocumentID,
_rev: rev,
_revisions: {
start: Number(rev.split("-")[0]),
ids: history,
},
type: "plain",
path: id,
children,
ctime: 1,
mtime: 1,
size: children.length,
eden: {},
} as unknown as FixtureDocument;
}
function liveSyncDatabaseFor(database: PouchDB.Database<FixtureContent>): LiveSyncLocalDB {
const subject = Object.create(LiveSyncLocalDB.prototype) as LiveSyncLocalDB;
Object.assign(subject, {
localDatabase: database as unknown as PouchDB.Database<EntryDoc>,
});
return subject;
}
function requiredEnvironment(name: "hostname" | "username" | "password"): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required integration-test environment variable: ${name}`);
}
return value;
}
describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => {
it("propagates collection safely, completes compaction, and permits content-addressed chunk recreation", async () => {
const databaseName = `livesync-gcv3-${crypto.randomUUID()}`;
const local = new PouchDB<FixtureContent>(`${databaseName}-source`, { adapter: "memory" });
const replica = new PouchDB<FixtureContent>(`${databaseName}-replica`, { adapter: "memory" });
const remote = new PouchDB<FixtureContent>(
`${requiredEnvironment("hostname").replace(/\/+$/u, "")}/${databaseName}`,
{
adapter: "http",
auth: {
username: requiredEnvironment("username"),
password: requiredEnvironment("password"),
},
}
);
try {
await remote.info();
await local.bulkDocs([
chunk("h:obsolete"),
chunk("h:current"),
chunk("h:shared"),
chunk("h:base"),
chunk("h:left"),
chunk("h:right"),
]);
const firstRevision = revision("first.md", "1-first", ["first"], ["h:obsolete"]);
delete firstRevision._rev;
delete firstRevision._revisions;
await local.put(firstRevision);
await local.put({
...(await local.get("first.md")),
children: ["h:current"],
});
for (const id of ["second.md", "third.md"]) {
const sharedRevision = revision(id, `1-${id}`, [id], ["h:shared"]);
delete sharedRevision._rev;
delete sharedRevision._revisions;
await local.put(sharedRevision);
}
await local.bulkDocs(
[
revision("conflicted.md", "1-base", ["base"], ["h:base"]),
revision("conflicted.md", "2-left", ["left", "base"], ["h:left"]),
revision("conflicted.md", "2-right", ["right", "base"], ["h:right"]),
],
{ new_edits: false }
);
const liveSyncDatabase = liveSyncDatabaseFor(local);
const replicationModes: string[] = [];
const replicator = {
openOneShotReplication: vi.fn(
async (
_settings: typeof DEFAULT_SETTINGS,
_showResult: boolean,
_ignoreCleanLock: boolean,
mode: string
) => {
replicationModes.push(mode);
if (mode === "sync") {
await local.sync(remote);
} else if (mode === "pushOnly") {
await local.replicate.to(remote);
} else {
throw new Error(`Unexpected replication mode: ${mode}`);
}
return true;
}
),
getConnectedDeviceList: vi.fn(() =>
Promise.resolve({
accepted_nodes: ["integration-device"],
node_info: {
"integration-device": {
progress: "10-local",
device_name: "Integration device",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
})
),
connectRemoteCouchDBWithSetting: vi.fn(() => Promise.resolve({ db: remote })),
};
const notice = vi.fn();
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
Object.assign(maintenance, {
core: {
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
replicator,
confirm: {
askSelectStringDialogue: vi.fn(() => Promise.resolve("Proceed Garbage Collection")),
},
},
localDatabase: liveSyncDatabase,
_notice: notice,
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
const clearHash = vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);
await maintenance.gcv3();
expect(replicationModes).toEqual(["sync", "pushOnly"]);
expect(clearHash).toHaveBeenCalledOnce();
expect(notice).toHaveBeenCalledWith("Compaction on remote database completed successfully.", "gc-compact");
expect(notice).not.toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact");
expect(notice).not.toHaveBeenCalledWith("Compaction on remote database failed.", "gc-compact");
const obsoleteRow = (await remote.allDocs({ keys: ["h:obsolete"] })).rows[0];
expect(obsoleteRow).toMatchObject({
id: "h:obsolete",
value: {
deleted: true,
},
});
for (const retainedChunk of ["h:current", "h:shared", "h:base", "h:left", "h:right"]) {
await expect(remote.get(retainedChunk)).resolves.toMatchObject({
_id: retainedChunk,
type: "leaf",
});
}
await expect(remote.get("second.md")).resolves.toMatchObject({ children: ["h:shared"] });
await expect(remote.get("third.md")).resolves.toMatchObject({ children: ["h:shared"] });
await expect(remote.get("conflicted.md", { conflicts: true })).resolves.toMatchObject({
_conflicts: [expect.any(String)],
});
await remote.replicate.to(replica);
await expect(replica.get("h:obsolete")).rejects.toMatchObject({ status: 404 });
for (const retainedChunk of ["h:current", "h:shared", "h:base", "h:left", "h:right"]) {
await expect(replica.get(retainedChunk)).resolves.toMatchObject({
_id: retainedChunk,
type: "leaf",
});
}
await local.put(chunk("h:obsolete", "recreated"));
await local.replicate.to(remote);
await remote.replicate.to(replica);
await expect(remote.get("h:obsolete")).resolves.toMatchObject({
_id: "h:obsolete",
type: "leaf",
data: "recreated",
});
await expect(replica.get("h:obsolete")).resolves.toMatchObject({
_id: "h:obsolete",
type: "leaf",
data: "recreated",
});
} finally {
await Promise.all([local.destroy(), replica.destroy(), remote.destroy()]);
}
}, 30_000);
});
@@ -4,6 +4,7 @@ import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
type DocumentID,
type EntryDoc,
type EntryLeaf,
@@ -38,16 +39,27 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands {
id: "analyse-database",
name: "Analyse Database Usage (advanced)",
icon: "database-search",
callback: async () => {
await this.analyseDatabase();
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode || !this._isDatabaseReady()) return false;
if (!checking) {
void this.analyseDatabase();
}
return true;
},
});
this.plugin.addCommand({
id: "gc-v3",
name: "Garbage Collection V3 (advanced, beta)",
icon: "trash-2",
callback: async () => {
await this.gcv3();
checkCallback: (checking) => {
const isApplicableRemote = this.settings.remoteType === REMOTE_COUCHDB;
if (!this.settings.useEdgeCaseMode || !this._isDatabaseReady() || !isApplicableRemote) {
return false;
}
if (!checking) {
void this.gcv3();
}
return true;
},
});
eventHub.onEvent(EVENT_ANALYSE_DB_USAGE, () => this.analyseDatabase());
@@ -450,7 +462,7 @@ Note: **Make sure to synchronise all devices before deletion.**
const confirmMessage = `This function deletes unused chunks from the device. If there are differences between devices, some chunks may be missing when resolving conflicts.
Be sure to synchronise before executing.
However, if you have deleted them, you may be able to recover them by performing Hatch -> Recreate missing chunks for all files.
If chunks used by current Vault files are deleted, Hatch -> Recreate chunks for current Vault files can recreate them only from files currently present in the Vault. It cannot recover unreadable historical or conflict content.
Are you ready to delete unused chunks?`;
@@ -748,7 +760,7 @@ Success: ${successCount}, Errored: ${errored}`;
timeout -= 2000;
if (timeout <= 0) {
this._notice("Compaction on remote database timed out.", "gc-compact");
break;
return;
}
} else {
break;
@@ -871,9 +883,14 @@ It is preferable to update all devices if possible. If you have any devices that
}
//2. Check whether the progress values in NodeData are roughly the same (only the numerical part is needed).
const progressValues = Object.values(node_info)
.map((e) => e.progress.split("-")[0])
.map((e) => parseInt(e));
const progressValues = Object.values(node_info).map((entry) => {
const progress = typeof entry.progress === "string" ? entry.progress.split("-")[0] : "";
return /^\d+$/u.test(progress) ? Number(progress) : Number.NaN;
});
if (progressValues.length === 0 || progressValues.some((progress) => !Number.isSafeInteger(progress))) {
this._notice("No connected device information found. Cancelling Garbage Collection.");
return;
}
const maxProgress = Math.max(...progressValues);
const minProgress = Math.min(...progressValues);
const progressDifference = maxProgress - minProgress;
@@ -912,41 +929,22 @@ This may indicate that some devices have not completed synchronisation, which co
const gcStartTime = Date.now();
// Perform Garbage Collection (new implementation).
const localDatabase = this.localDatabase.localDatabase;
const usedChunks = new Set<DocumentID>();
const allChunks = new Map<DocumentID, string>();
const IDs = this.localDatabase.findEntryNames("", "", {});
let i = 0;
const doc_count = (await localDatabase.info()).doc_count;
for await (const id of IDs) {
const doc = await this.localDatabase.getRaw(id as DocumentID);
i++;
if (i % 100 == 0) {
this._notice(`Garbage Collection: Scanned ${i} / ~${doc_count} `, "gc-scanning");
}
if (!doc) continue;
if ("children" in doc) {
const children = (doc.children || []) as DocumentID[];
for (const chunkId of children) {
usedChunks.add(chunkId);
}
} else if (doc.type === EntryTypes.CHUNK) {
allChunks.set(doc._id, doc._rev);
}
}
// Use the revision-aware reachability scan. Reading only winning revisions
// would make chunks used exclusively by live conflict branches look unused.
const { used: usedChunks, existing: allChunks } = await this.localDatabase.allChunks();
this._notice(
`Garbage Collection: Scanning completed. Total chunks: ${allChunks.size}, Used chunks: ${usedChunks.size}`,
"gc-scanning"
);
const unusedChunks = [...allChunks.keys()].filter((e) => !usedChunks.has(e));
const unusedChunks = [...allChunks.entries()].filter(([chunkId]) => !usedChunks.has(chunkId));
this._notice(`Garbage Collection: Found ${unusedChunks.length} unused chunks to delete.`, "gc-scanning");
const deleteChunkDocs = unusedChunks.map(
(chunkId) =>
([chunkId, chunk]) =>
({
_id: chunkId,
_id: chunkId as DocumentID,
_deleted: true,
_rev: allChunks.get(chunkId),
_rev: chunk._rev,
}) as EntryLeaf
);
const response = await localDatabase.bulkDocs(deleteChunkDocs);
@@ -1,5 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("octagonal-wheels/number", () => ({
sizeToHumanReadable: vi.fn((value: number) => `${value} B`),
}));
vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({
serialized: vi.fn((_key: string, task: () => unknown) => task()),
}));
vi.mock("octagonal-wheels/collection", () => ({
arrayToChunkedArray: vi.fn((values: unknown[]) => [values]),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
delay: vi.fn(async () => undefined),
};
});
vi.mock("@/features/LiveSyncCommands", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { settings: unknown };
get settings() {
return this.core.settings;
}
},
}));
vi.mock("@/common/events", () => ({
EVENT_ANALYSE_DB_USAGE: "analyse",
EVENT_REQUEST_PERFORM_GC_V3: "gc",
eventHub: {
onEvent: vi.fn(),
},
}));
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
@@ -22,6 +60,52 @@ function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS>
}
describe("LocalDatabaseMaintenance prerequisites", () => {
it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings: {
useAdvancedMode: boolean;
useEdgeCaseMode: boolean;
remoteType: string;
} = {
useAdvancedMode: false,
useEdgeCaseMode: false,
remoteType: REMOTE_COUCHDB,
};
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
Object.assign(maintenance, {
plugin: {
addCommand: vi.fn((command) => commands.push(command)),
},
core: {
settings,
},
_isDatabaseReady: vi.fn(() => true),
});
maintenance.onload();
const analyse = commands.find(({ id }) => id === "analyse-database");
const garbageCollect = commands.find(({ id }) => id === "gc-v3");
expect(analyse?.checkCallback?.(true)).toBe(false);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useAdvancedMode = true;
expect(analyse?.checkCallback?.(true)).toBe(true);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useEdgeCaseMode = true;
expect(garbageCollect?.checkCallback?.(true)).toBe(true);
settings.remoteType = REMOTE_P2P;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.remoteType = REMOTE_MINIO;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
});
it("asks to disable on-demand chunk fetching before maintenance actions", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
@@ -88,7 +172,7 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
expect(applyPartial).not.toHaveBeenCalled();
});
it("does not treat the obsolete fixed-revision key as a maintenance prerequisite", async () => {
it("retirement guard: ignores the obsolete fixed-revision key as a maintenance prerequisite", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
doNotUseFixedRevisionForChunks: false,
readChunksOnline: false,
@@ -107,4 +191,231 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(applyPartial).not.toHaveBeenCalled();
});
it("describes the current chunk-recreation action without promising historical recovery", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const askSelectStringDialogue = vi.fn().mockResolvedValue("Cancel");
Object.assign(maintenance, {
core: {
confirm: {
askSelectStringDialogue,
},
},
_log: vi.fn(),
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "trackChanges").mockResolvedValue(undefined);
await maintenance.performGC();
const message = vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0] as string;
expect(message).toContain("Hatch -> Recreate chunks for current Vault files");
expect(message).toContain("only from files currently present in the Vault");
expect(message).not.toContain("Recreate missing chunks for all files");
});
});
describe("LocalDatabaseMaintenance Garbage Collection V3", () => {
it("does not report remote compaction as successful after its completion wait times out", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const notice = vi.fn();
const remoteDatabase = {
compact: vi.fn(async () => ({ ok: true })),
info: vi.fn(async () => ({ compact_running: true })),
};
Object.assign(maintenance, {
core: {
replicator: {
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
},
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
},
_notice: notice,
});
await maintenance.compactDatabase();
expect(notice).toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact");
expect(notice).not.toHaveBeenCalledWith(
"Compaction on remote database completed successfully.",
"gc-compact"
);
});
it.each([
["no device progress entries", {}],
[
"an unparseable device progress entry",
{
"device-a": {
progress: "",
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
],
[
"a missing device progress entry",
{
"device-a": {
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
],
] as const)("cancels before collection when the milestone has %s", async (_case, nodeInfo) => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const pushModes: string[] = [];
const allChunks = vi.fn(async () => ({
used: new Set<string>(),
existing: new Map(),
}));
const replicator = {
openOneShotReplication: vi.fn(async (...args: unknown[]) => {
pushModes.push(String(args[3]));
return true;
}),
getConnectedDeviceList: vi.fn(async () => ({
accepted_nodes: Object.keys(nodeInfo),
node_info: nodeInfo,
})),
};
const notice = vi.fn();
Object.assign(maintenance, {
core: {
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
replicator,
confirm: {
askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
},
},
localDatabase: {
allChunks,
localDatabase: {
bulkDocs: vi.fn(async () => []),
},
},
_notice: notice,
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);
await maintenance.gcv3();
expect(allChunks).not.toHaveBeenCalled();
expect(pushModes).toEqual(["sync"]);
expect(notice).toHaveBeenCalledWith(
"No connected device information found. Cancelling Garbage Collection."
);
});
it("keeps chunks referenced by a live conflict revision and deletes only unreachable chunks", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const pushModes: string[] = [];
const deletedChunks: Array<{ _id: string; _rev?: string; _deleted?: boolean }> = [];
const allChunks = vi.fn(async () => ({
used: new Set(["h:winner", "h:conflict"]),
existing: new Map([
["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
]),
}));
const rawDocuments = new Map<string, object>([
[
"note.md",
{
_id: "note.md",
_rev: "2-winner",
_conflicts: ["2-conflict"],
type: "plain",
children: ["h:winner"],
},
],
["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
]);
const findEntryNames = vi.fn(async function* () {
yield* rawDocuments.keys();
});
const getRaw = vi.fn(async (id: string) => rawDocuments.get(id));
const localDatabase = {
allChunks,
localDatabase: {
info: vi.fn(async () => ({ doc_count: rawDocuments.size })),
bulkDocs: vi.fn(async (docs: Array<{ _id: string; _rev?: string; _deleted?: boolean }>) => {
deletedChunks.push(...docs);
return docs.map(({ _id }) => ({ ok: true, id: _id, rev: "2-deleted" }));
}),
},
findEntryNames,
getRaw,
};
const replicator = {
openOneShotReplication: vi.fn(
async (
_settings: typeof DEFAULT_SETTINGS,
_showResult: boolean,
_ignoreCleanLock: boolean,
mode: string
) => {
pushModes.push(mode);
return true;
}
),
getConnectedDeviceList: vi.fn(async () => ({
accepted_nodes: ["device-a"],
node_info: {
"device-a": {
progress: "10-local",
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
})),
};
Object.assign(maintenance, {
core: {
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
replicator,
confirm: {
askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
},
},
localDatabase,
_notice: vi.fn(),
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);
await maintenance.gcv3();
expect(allChunks).toHaveBeenCalledOnce();
expect(findEntryNames).not.toHaveBeenCalled();
expect(getRaw).not.toHaveBeenCalled();
expect(deletedChunks).toEqual([
{
_id: "h:obsolete",
_rev: "1-obsolete",
_deleted: true,
},
]);
expect(pushModes).toEqual(["sync", "pushOnly"]);
expect(maintenance.compactDatabase).toHaveBeenCalledOnce();
});
});
@@ -163,4 +163,16 @@ describe("createOpenRebuildUI", () => {
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);
expect(replicator.clearOnSetup).toHaveBeenCalledOnce();
});
it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => {
const replicator = createReplicator();
const session = createOpenRebuildUI({} as any)(replicator)(true);
const modal = modalState.instances[0];
modal.onClosed?.();
await expect(session).resolves.toBe(false);
expect(replicator.replicateFrom).not.toHaveBeenCalled();
expect(replicator.setOnSetup).not.toHaveBeenCalled();
});
});
@@ -13,6 +13,7 @@
import type { P2PReplicatorStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { $msg as translateMessage } from "@/common/translation";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -134,15 +135,16 @@
{#if showBroadcastToggle}
<div class="status-item status-action broadcast-row">
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
<label class="broadcast-label" for="broadcast-toggle">
Live-push to peers
{translateMessage("Announce changes")}
</label>
<button
id="broadcast-toggle"
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
onclick={toggleBroadcast}
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
title={isBroadcasting
? translateMessage("Stop announcing changes")
: translateMessage("Start announcing changes")}
>
{isBroadcasting ? '📡 On' : '📡 Off'}
</button>
@@ -24,6 +24,13 @@
import { extractP2PRoomSuffix } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
import { Menu } from "@/deps";
import { $msg as translateMessage } from "@/common/translation";
import {
hasExactP2PPeer,
togglePersistedP2PPeer,
type PersistedP2PPeerSetting,
} from "./p2pPeerSettings";
interface Props {
getLiveSyncReplicator: () => LiveSyncTrysteroReplicator;
@@ -40,7 +47,6 @@
// Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time.
const readCurrentSettings = () => core.services.setting.currentSettings();
const initialSettings = readCurrentSettings();
let syncOnReplicationSetting = $state(initialSettings?.P2P_SyncOnReplication ?? "");
type P2PRemoteOption = {
id: string;
name: string;
@@ -50,21 +56,7 @@
let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? "");
let selectingP2PRemote = $state(false);
function addToList(item: string, list: string): string {
const items = list
.split(",")
.map((e) => e.trim())
.filter((e) => e);
if (!items.includes(item)) items.push(item);
return items.join(",");
}
function removeFromList(item: string, list: string): string {
return list
.split(",")
.map((e) => e.trim())
.filter((e) => e && e !== item)
.join(",");
}
let peerMenu: Menu | undefined;
function markCommunicating(peerId: string) {
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
@@ -154,7 +146,6 @@
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
});
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
@@ -174,6 +165,7 @@
unsubscribeReplicatorProgress();
unsubscribeSettings();
unsubscribeLayoutReady();
peerMenu?.hide();
};
});
@@ -206,8 +198,6 @@
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
} finally {
selectingP2PRemote = false;
@@ -252,8 +242,6 @@
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
}
@@ -299,7 +287,6 @@
const activated = activateP2PRemoteConfiguration(settings, selectedId);
return activated || settings;
}, true);
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
}
async function makeDecision(
@@ -374,23 +361,49 @@
return isLiveCommunicating || isHeldCommunicating;
}
function isSyncTarget(peerName: string) {
return syncOnReplicationSetting
.split(",")
.map((e) => e.trim())
.filter((e) => e)
.includes(peerName);
function isPersistedPeerSettingEnabled(setting: PersistedP2PPeerSetting, peerName: string) {
const settings = core.services.setting.currentSettings();
return hasExactP2PPeer(settings[setting] ?? "", peerName);
}
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
async function togglePersistedPeerSetting(
peer: P2PServerInfo["knownAdvertisements"][number],
setting: PersistedP2PPeerSetting
) {
if (!canEditP2PSettings()) {
return;
}
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
const newValue = isSyncTarget(peer.name)
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue);
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
const currentSettings = core.services.setting.currentSettings();
await updateSelectedP2PRemote(togglePersistedP2PPeer(currentSettings, setting, peer.name));
}
function openPeerMenu(event: MouseEvent, peer: P2PServerInfo["knownAdvertisements"][number]) {
peerMenu?.hide();
peerMenu = new Menu()
.addItem((item) => {
item.setTitle(translateMessage("Synchronise when this device connects"))
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoSyncPeers", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_AutoSyncPeers");
});
})
.addItem((item) => {
item.setTitle(translateMessage("Follow whenever this device connects"))
.setChecked(isPersistedPeerSettingEnabled("P2P_AutoWatchPeers", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_AutoWatchPeers");
});
})
.addItem((item) => {
item.setTitle(translateMessage("Include in the P2P synchronisation command"))
.setChecked(isPersistedPeerSettingEnabled("P2P_SyncOnReplication", peer.name))
.onClick(() => {
void togglePersistedPeerSetting(peer, "P2P_SyncOnReplication");
});
});
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
peerMenu.showAtPosition({ x: rect.left, y: rect.bottom });
}
</script>
@@ -486,35 +499,34 @@
>
Revoke
</button>
<button
class="emoji-button"
title={translateMessage("More actions for ${DEVICE}", { DEVICE: peer.name })}
aria-label={translateMessage("More actions for ${DEVICE}", {
DEVICE: peer.name,
})}
disabled={!canEditP2PSettings()}
onclick={(event) => openPeerMenu(event, peer)}
>
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">WATCH</span>
<span class="decision-label">{translateMessage("Follow changes")}</span>
<button
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
title={isWatching(peer.peerId)
? "Watching this peer \u2014 click to stop"
: "Watch this peer's changes"}
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"}
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
aria-label={isWatching(peer.peerId)
? translateMessage("Stop following changes from this device")
: translateMessage("Follow changes from this device")}
disabled={!canEditP2PSettings()}
onclick={() => toggleWatch(peer.peerId)}
>
{isWatching(peer.peerId) ? "🔔" : "🔕"}
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">SYNC</span>
<button
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
title={isSyncTarget(peer.name)
? "Sync target \u2014 click to remove"
: "Set as sync target"}
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
disabled={!canEditP2PSettings()}
onclick={() => toggleSyncTarget(peer)}
>
{isSyncTarget(peer.name) ? "🔗" : "⛓️‍💥"}
</button>
</div>
{:else}
<div class="decision-status">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
@@ -805,7 +817,7 @@
}
.accepted-row {
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto auto auto;
}
.decision-label {
@@ -0,0 +1,38 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
export type PersistedP2PPeerSetting =
| "P2P_AutoSyncPeers"
| "P2P_AutoWatchPeers"
| "P2P_SyncOnReplication";
function splitPeerSetting(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
}
export function hasExactP2PPeer(value: string, peerName: string): boolean {
return splitPeerSetting(value).includes(peerName);
}
export function toggleExactP2PPeer(value: string, peerName: string): string {
const items = splitPeerSetting(value);
const existingIndex = items.indexOf(peerName);
if (existingIndex >= 0) {
items.splice(existingIndex, 1);
} else {
items.push(peerName);
}
return [...new Set(items)].join(",");
}
export function togglePersistedP2PPeer(
settings: ObsidianLiveSyncSettings,
setting: PersistedP2PPeerSetting,
peerName: string
): Partial<ObsidianLiveSyncSettings> {
return {
[setting]: toggleExactP2PPeer(settings[setting] ?? "", peerName),
};
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
import {
hasExactP2PPeer,
toggleExactP2PPeer,
togglePersistedP2PPeer,
} from "./p2pPeerSettings";
describe("persisted P2P peer controls", () => {
it("adds and removes only the selected peer without disturbing advanced patterns", () => {
const original = "~^phone-,desktop";
const added = toggleExactP2PPeer(original, "phone-main");
expect(added).toBe("~^phone-,desktop,phone-main");
expect(hasExactP2PPeer(added, "phone-main")).toBe(true);
const removed = toggleExactP2PPeer(added, "phone-main");
expect(removed).toBe(original);
});
it.each([
"P2P_AutoSyncPeers",
"P2P_AutoWatchPeers",
"P2P_SyncOnReplication",
] as const)("updates %s through the same profile-backed setting boundary", (setting) => {
const settings = {
P2P_AutoSyncPeers: "",
P2P_AutoWatchPeers: "",
P2P_SyncOnReplication: "",
} as ObsidianLiveSyncSettings;
expect(togglePersistedP2PPeer(settings, setting, "peer-a")).toEqual({
[setting]: "peer-a",
});
});
});
+2
View File
@@ -46,6 +46,7 @@ import { useReviewHarness } from "./serviceFeatures/useReviewHarness.ts";
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
import { createFileReflectionProvenance } from "./serviceModules/FileReflectionProvenance.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -104,6 +105,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
const rebuilder = new ServiceRebuilder({
events: services.context.events,
+2 -1
View File
@@ -145,7 +145,8 @@ export class ModuleReplicator extends AbstractModule {
}
/**
* obsolete method. No longer maintained and will be removed in the future.
* Reconciles local chunks when an older IndexedDB client reports that the remote database was cleaned.
* This compatibility path remains reachable while those clients can still set `remoteCleaned`.
* @deprecated v0.24.17
* @param showMessage If true, show message to the user.
*/
@@ -116,7 +116,7 @@ describe("ModuleReplicator", () => {
});
});
describe("ModuleReplicator legacy cleanup", () => {
describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", () => {
it("keeps its finite replication and balancing work inside the shared activity boundary", async () => {
const activityFinished = vi.fn();
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
@@ -16,16 +16,10 @@ import { TARGET_IS_NEW } from "@vrtmrz/livesync-commonlib/compat/common/models/s
import { compareMTime, displayRev } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import diff_match_patch from "diff-match-patch";
import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { eventHub } from "@/common/events.ts";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
declare global {
interface LSEvents {
"conflict-cancelled": FilePathWithPrefix;
}
}
export class ModuleConflictResolver extends AbstractModule {
private async _resolveConflictByDeletingRev(
path: FilePathWithPrefix,
@@ -41,7 +35,7 @@ export class ModuleConflictResolver extends AbstractModule {
);
return MISSING_OR_ERROR;
}
eventHub.emitEvent("conflict-cancelled", path);
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, path);
this._log(
`${title} Conflicted revision has been deleted ${displayRev(deleteRevision)} ${path}`,
LOG_LEVEL_INFO
@@ -92,8 +86,11 @@ export class ModuleConflictResolver extends AbstractModule {
return MISSING_OR_ERROR;
}
if (rightLeaf == false) {
// Conflicted item could not load, delete this.
return await this.services.conflict.resolveByDeletingRevision(path, rightRev, "MISSING OLD REV");
// A locally unreadable conflict leaf may still be recoverable from another
// replica or backup. Keep it visible for explicit repair instead of treating
// missing chunks as evidence that the branch is obsolete.
this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE);
return MISSING_OR_ERROR;
}
const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted;
@@ -131,11 +128,12 @@ export class ModuleConflictResolver extends AbstractModule {
// const filename = filenames[0];
return await serialized(`conflict-resolve:${filename}`, async () => {
const conflictCheckResult = await this.checkConflictAndPerformAutoMerge(filename);
if (
conflictCheckResult === MISSING_OR_ERROR ||
conflictCheckResult === NOT_CONFLICTED ||
conflictCheckResult === CANCELLED
) {
if (conflictCheckResult === NOT_CONFLICTED) {
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
}
if (conflictCheckResult === MISSING_OR_ERROR || conflictCheckResult === CANCELLED) {
// nothing to do.
this._log(`[conflict] Not conflicted or cancelled: ${filename}`, LOG_LEVEL_VERBOSE);
return;
@@ -161,7 +159,7 @@ export class ModuleConflictResolver extends AbstractModule {
}
}
this._log("[conflict] Manual merge required!");
eventHub.emitEvent("conflict-cancelled", filename);
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, filename);
await this.services.conflict.resolveByUserInteraction(filename, conflictCheckResult);
});
}
@@ -1,14 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
DEFAULT_SETTINGS,
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
MISSING_OR_ERROR,
type FilePathWithPrefix,
type MetaEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleConflictResolver } from "./ModuleConflictResolver";
function createModule(files: FilePathWithPrefix[] = []) {
const resolveByDeletingRevision = vi.fn(async () => AUTO_MERGED);
const tryAutoMerge = vi.fn();
const queueCheckFor = vi.fn(async () => undefined);
const resolveByUserInteraction = vi.fn(async () => false);
const core = {
_services: {
API: {
@@ -23,6 +29,18 @@ function createModule(files: FilePathWithPrefix[] = []) {
},
conflict: {
resolveByNewest: vi.fn(async () => true),
resolveByDeletingRevision,
resolveByUserInteraction,
queueCheckFor,
},
appLifecycle: {
isSuspended: vi.fn(() => false),
},
replication: {
replicateByEvent: vi.fn(async () => true),
},
vault: {
getActiveFilePath: vi.fn(() => undefined),
},
},
settings: DEFAULT_SETTINGS,
@@ -32,6 +50,10 @@ function createModule(files: FilePathWithPrefix[] = []) {
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => []),
storeContent: vi.fn(async () => true),
},
localDatabase: {
tryAutoMerge,
},
storageAccess: {
getFileNames: vi.fn(async () => files),
@@ -41,7 +63,7 @@ function createModule(files: FilePathWithPrefix[] = []) {
const module = new ModuleConflictResolver(core);
module._log = vi.fn();
return { module };
return { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge };
}
describe("ModuleConflictResolver bulk newest resolution", () => {
@@ -116,3 +138,131 @@ describe("ModuleConflictResolver bulk newest resolution", () => {
expect(module._log).toHaveBeenCalledTimes(3);
});
});
describe("ModuleConflictResolver independent same-path creation", () => {
const path = "independently-created.md" as FilePathWithPrefix;
function leaf(rev: string, data: string, mtime: number) {
return {
rev,
data,
mtime,
ctime: mtime,
deleted: false,
} as any;
}
it("collapses one duplicate revision when independently created files have identical content", async () => {
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
const leftLeaf = leaf("1-left", "Same content\n", 1000);
const rightLeaf = leaf("1-right", "Same content\n", 2000);
tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(resolveByDeletingRevision).toHaveBeenCalledOnce();
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "1-left", "same");
});
it("returns a manual diff when independently created files have different content", async () => {
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
const leftLeaf = leaf("1-left", "Left content\n", 1000);
const rightLeaf = leaf("1-right", "Right content\n", 2000);
tryAutoMerge.mockResolvedValue({
leftRev: leftLeaf.rev,
rightRev: rightLeaf.rev,
leftLeaf,
rightLeaf,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toMatchObject({ left: leftLeaf, right: rightLeaf });
expect(result).toHaveProperty("diff");
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
});
});
describe("ModuleConflictResolver sensible merge hand-off", () => {
it("keeps an unreadable non-winner revision unresolved", async () => {
const path = "missing-conflict-body.md" as FilePathWithPrefix;
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
tryAutoMerge.mockResolvedValue({
leftRev: "3-current",
rightRev: "2-unreadable",
leftLeaf: {
rev: "3-current",
data: "Readable current body\n",
ctime: 1,
mtime: 3,
deleted: false,
},
rightLeaf: false,
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(MISSING_OR_ERROR);
expect(resolveByDeletingRevision).not.toHaveBeenCalled();
});
it("stores the merged body and removes the resolved conflict leaf", async () => {
const path = "sensible.md" as FilePathWithPrefix;
const { module, resolveByDeletingRevision, tryAutoMerge } = createModule();
tryAutoMerge.mockResolvedValue({
result: "Title\nLeft changed\nRight changed\n",
conflictedRev: "2-right",
});
const result = await module.checkConflictAndPerformAutoMerge(path);
expect(result).toBe(AUTO_MERGED);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(
path,
"Title\nLeft changed\nRight changed\n"
);
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "Sensible");
});
it("commits a sensible pair before rechecking the remaining manual pair", async () => {
const path = "three-versions.md" as FilePathWithPrefix;
const { module, queueCheckFor, resolveByDeletingRevision, resolveByUserInteraction, tryAutoMerge } =
createModule();
const remainingManualPair = {
leftRev: "3-merged",
rightRev: "2-third",
leftLeaf: { rev: "3-merged", data: "Merged\n", ctime: 1, mtime: 3 },
rightLeaf: { rev: "2-third", data: "Overlapping\n", ctime: 1, mtime: 2 },
};
tryAutoMerge
.mockResolvedValueOnce({
result: "Merged\n",
conflictedRev: "2-second",
})
.mockResolvedValueOnce(remainingManualPair);
await (module as any)._resolveConflict(path);
expect(module.core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "Merged\n");
expect(resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-second", "Sensible");
expect(queueCheckFor).toHaveBeenCalledWith(path);
expect(resolveByUserInteraction).not.toHaveBeenCalled();
await (module as any)._resolveConflict(path);
expect(tryAutoMerge).toHaveBeenCalledTimes(2);
expect(resolveByUserInteraction).toHaveBeenCalledWith(
path,
expect.objectContaining({
left: remainingManualPair.leftLeaf,
right: remainingManualPair.rightLeaf,
})
);
});
});
@@ -29,8 +29,6 @@ function valueToString(value: string | number | boolean | object | undefined): s
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private _hasNotifiedAutoAcceptCompatibleUndefined = false;
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
const items = Object.keys(
TweakValuesShouldMatchedTemplate
@@ -64,33 +62,17 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
);
if (!hasOnlyCompatibleLossyMismatches) return undefined;
let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak;
if (this.settings.autoAcceptCompatibleTweak === undefined) {
if (this._hasNotifiedAutoAcceptCompatibleUndefined) {
return undefined;
}
this._hasNotifiedAutoAcceptCompatibleUndefined = true;
const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible");
const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible");
const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const;
const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined");
const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"),
timeout: 0,
defaultAction: CHOICE_ENABLE,
});
if (ret !== CHOICE_ENABLE) {
return undefined;
}
await this.services.setting.applyPartial(
{
autoAcceptCompatibleTweak: true,
},
true
);
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
// Keep the settings object stable: settings panes and an in-flight replication retry can
// retain this reference while the default is persisted.
this.settings.autoAcceptCompatibleTweak = true;
await this.services.setting.saveSettingData();
autoAcceptCompatibleTweak = true;
Logger("Automatic alignment of compatible chunk settings has been enabled.");
}
if (this.settings.autoAcceptCompatibleTweak !== true) return undefined;
if (autoAcceptCompatibleTweak !== true) return undefined;
return this._selectNewerTweakSide(current, preferred);
}
@@ -215,7 +197,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
} else if (rebuildRecommended) {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [true, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
} else {
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
@@ -255,9 +237,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
return "CHECKAGAIN";
}
if (conf) {
this.settings = { ...this.settings, ...conf };
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
// ReplicationService retains the current settings object while it performs the immediate
// CHECKAGAIN retry. Update that object in place so the retry observes the accepted values.
Object.assign(this.settings, extractObject(TweakValuesTemplate, conf));
await this.services.setting.saveSettingData();
if (!rebuildRequired) {
// The failed replication has settled before mismatch resolution runs. Reinitialise the
// chunk-generation managers now so hash and splitter changes take effect before retrying.
await this.localDatabase.managers.reinitialise();
}
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
if (rebuildRequired) {
await this.core.rebuilder.$fetchLocal();
}
@@ -1,9 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
type RemoteDBSettings,
type TweakValues,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn(async () => undefined);
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
const applyPartial = vi.fn(async (_partial: Record<string, unknown>): Promise<void> => undefined);
const reinitialise = vi.fn(async () => undefined);
const core = {
_services: {
API: {
@@ -15,6 +22,12 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
},
setting: {
saveSettingData: vi.fn(async () => undefined),
applyPartial,
},
},
localDatabase: {
managers: {
reinitialise,
},
},
settings: {
@@ -26,6 +39,9 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
askSelectStringDialogue,
},
} as any;
applyPartial.mockImplementation(async (partial: Record<string, unknown>) => {
core.settings = { ...core.settings, ...partial };
});
Object.defineProperty(core, "services", {
get() {
@@ -34,10 +50,35 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
});
const module = new ModuleResolvingMismatchedTweaks(core);
return { module, core, askSelectStringDialogue };
return { module, core, askSelectStringDialogue, applyPartial, reinitialise };
}
describe("ModuleResolvingMismatchedTweaks", () => {
it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => {
const { module, core, askSelectStringDialogue, applyPartial } = createModule({
autoAcceptCompatibleTweak: undefined,
hashAlg: "xxhash64",
tweakModified: 100,
});
const initialSettings = core.settings;
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: 200,
} as Partial<TweakValues>;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(false);
expect(core.settings).toBe(initialSettings);
expect(core.settings.autoAcceptCompatibleTweak).toBe(true);
expect(core._services.setting.saveSettingData).toHaveBeenCalledTimes(1);
expect(applyPartial).not.toHaveBeenCalled();
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
@@ -58,6 +99,28 @@ describe("ModuleResolvingMismatchedTweaks", () => {
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it.each([
{ label: "neither side has a recorded time", currentModified: 0, preferredModified: 0 },
{ label: "the recorded times are equal", currentModified: 200, preferredModified: 200 },
])("should use the remote compatible value when $label", async ({ currentModified, preferredModified }) => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: currentModified,
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: preferredModified,
} as Partial<TweakValues>;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(false);
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
@@ -80,6 +143,24 @@ describe("ModuleResolvingMismatchedTweaks", () => {
expect(askSelectStringDialogue).toHaveBeenCalledTimes(1);
});
it("should fetch after applying a compatible remote setting when the user selects the rebuild option", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
hashAlg: "xxhash64",
});
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again");
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
} as TweakValues;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(true);
});
it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
@@ -105,4 +186,42 @@ describe("ModuleResolvingMismatchedTweaks", () => {
expect(result).toEqual({ result: false, requireFetch: false });
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should apply remote compatible settings in place and reinitialise managers before retrying", async () => {
const { module, core, reinitialise } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: 100,
});
const initialSettings = core.settings;
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: 200,
} as TweakValues;
const calls: string[] = [];
core._services.tweakValue = {
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, false]),
};
core._services.setting.saveSettingData = vi.fn(async () => {
calls.push("save");
});
core.replicator = {
tweakSettingsMismatched: true,
preferredTweakValue: preferred,
setPreferredRemoteTweakSettings: vi.fn(async () => {
calls.push("set-preferred");
}),
};
reinitialise.mockImplementation(async () => {
calls.push("reinitialise");
});
const result = await module._askResolvingMismatchedTweaks();
expect(result).toBe("CHECKAGAIN");
expect(core.settings).toBe(initialSettings);
expect(core.settings.hashAlg).toBe("xxhash32");
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
});
});
+23 -10
View File
@@ -2,24 +2,29 @@ import type { LiveSyncCore } from "@/main";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "@/modules/AbstractModule";
import { $msg } from "@/common/translation";
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
export class ModuleBasicMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-replicate",
name: "Replicate now",
name: $msg("Sync now"),
callback: async () => {
await this.services.replication.replicate();
},
});
this.addCommand({
id: "livesync-dump",
name: "Dump information of this doc ",
callback: () => {
name: $msg("Copy database information for the active file"),
checkCallback: (checking) => {
const file = this.services.vault.getActiveFilePath();
if (!file) return;
fireAndForget(() => this.localDatabase.getDBEntry(file, {}, true, false));
if (!file) return false;
if (!checking) {
fireAndForget(() => copyFileDatabaseInfo(this.core, file));
}
return true;
},
});
this.addCommand({
@@ -56,14 +61,18 @@ export class ModuleBasicMenu extends AbstractModule {
this.addCommand({
id: "livesync-scan-files",
name: "Scan storage and database again",
callback: async () => {
await this.services.vault.scanVault(true);
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
fireAndForget(() => this.services.vault.scanVault(true));
}
return true;
},
});
this.addCommand({
id: "livesync-runbatch",
name: "Run pended batch processes",
name: $msg("Apply pending changes now"),
callback: async () => {
await this.services.fileProcessing.commitPendingFileEvents();
},
@@ -73,8 +82,12 @@ export class ModuleBasicMenu extends AbstractModule {
this.addCommand({
id: "livesync-abortsync",
name: "Abort synchronization immediately",
callback: () => {
this.core.replicator.terminateSync();
checkCallback: (checking) => {
if (!this.settings.useAdvancedMode) return false;
if (!checking) {
this.core.replicator.terminateSync();
}
return true;
},
});
return Promise.resolve(true);
@@ -0,0 +1,170 @@
import { describe, expect, it, vi } from "vitest";
import type { Command } from "@/deps";
import { ModuleBasicMenu } from "./ModuleBasicMenu";
type RegisteredCommand = Command & {
checkCallback?: (checking: boolean) => boolean | void;
};
function createFixture() {
const commands: RegisteredCommand[] = [];
const settings = {
liveSync: false,
useAdvancedMode: false,
enableDebugTools: false,
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn((command: RegisteredCommand) => {
commands.push(command);
return command;
}),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
replication: {
replicate: vi.fn(async () => undefined),
},
vault: {
getActiveFilePath: vi.fn((): string | null => "note.md"),
scanVault: vi.fn(async () => undefined),
},
control: {
applySettings: vi.fn(async () => undefined),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
appLifecycle: {
isSuspended: vi.fn(() => false),
setSuspended: vi.fn(),
},
fileProcessing: {
commitPendingFileEvents: vi.fn(async () => true),
},
UI: {
promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true),
},
path: {
path2id: vi.fn(async () => "f:note"),
},
};
const core = {
settings,
_services: services,
services,
localDatabase: {
getDBEntry: vi.fn(async () => false),
localDatabase: {
get: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
children: ["h:private-chunk-id"],
eden: {},
})),
},
getDBEntryMeta: vi.fn(async () => ({
_id: "f:note",
_rev: "2-current",
_conflicts: [],
path: "note.md",
ctime: 100,
mtime: 200,
size: 12,
type: "plain",
datatype: "plain",
data: "",
children: ["h:private-chunk-id"],
eden: {},
})),
allDocsRaw: vi.fn(async () => ({
rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }],
})),
},
storageAccess: {
isExistsIncludeHidden: vi.fn(async () => true),
statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })),
},
replicator: {
terminateSync: vi.fn(),
},
};
const module = new ModuleBasicMenu(core as never);
return {
commands,
core,
module,
services,
settings,
getCommand(id: string) {
const command = commands.find((candidate) => candidate.id === id);
expect(command, `command ${id}`).toBeDefined();
return command!;
},
};
}
describe("ModuleBasicMenu command palette", () => {
it("uses clear user-facing names without changing the established command IDs", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now");
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
});
it("keeps maintenance commands out of the normal palette", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false);
fixture.settings.useAdvancedMode = true;
expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true);
expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true);
});
it("keeps active-file database information available and opens it in a copy dialogue", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
const command = fixture.getCommand("livesync-dump");
expect(command.name).toBe("Copy database information for the active file");
expect(command.checkCallback?.(true)).toBe(true);
command.checkCallback?.(false);
await vi.waitFor(() => {
expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce();
});
const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0];
expect(title).toBe("Database information for note.md");
expect(report).toContain("note.md");
expect(report).toContain("2-current");
expect(report).toContain("h:private-chunk-id");
expect(report).toContain("1-chunk");
expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled();
});
it("hides the active-file database report when no file is active", async () => {
const fixture = createFixture();
fixture.services.vault.getActiveFilePath.mockReturnValue(null);
await fixture.module._everyOnloadStart();
expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false);
});
});
@@ -1,6 +1,4 @@
import { type Editor, type MarkdownFileInfo, type MarkdownView } from "@/deps.ts";
import { addIcon } from "@/deps.ts";
import { type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { LiveSyncCore } from "@/main.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -22,16 +20,6 @@ export class ModuleObsidianMenu extends AbstractModule {
await this.services.replication.replicate(true);
}).addClass("livesync-ribbon-replicate");
this.addCommand({
id: "livesync-checkdoc-conflicted",
name: "Resolve if conflicted.",
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
const file = view.file;
if (!file) return;
void this.services.conflict.queueCheckForIfOpen(file.path as FilePathWithPrefix);
},
});
return Promise.resolve(true);
}
@@ -1,25 +1,26 @@
import { App, Modal } from "@/deps.ts";
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { eventHub } from "@/common/events.ts";
import { globalSlipBoard } from "@vrtmrz/livesync-commonlib/compat/bureau/bureau";
import {
CANCELLED,
LEAVE_TO_SUBSEQUENT,
type diff_result,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
import { promiseWithResolvers } from "octagonal-wheels/promises";
export type MergeDialogResult = typeof CANCELLED | typeof LEAVE_TO_SUBSEQUENT | string;
export const POSTPONED = Symbol("postponed");
declare global {
interface Slips {
"conflict-resolved": typeof CANCELLED | MergeDialogResult;
}
}
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
export class ConflictResolveModal extends Modal {
result: diff_result;
filename: string;
filename: FilePathWithPrefix;
response: MergeDialogResult = CANCELLED;
isClosed = false;
consumed = false;
private readonly resultPromise = promiseWithResolvers<MergeDialogResult>();
title: string = "Conflicting changes";
@@ -31,7 +32,13 @@ export class ConflictResolveModal extends Modal {
diffView!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
constructor(
app: App,
filename: FilePathWithPrefix,
diff: diff_result,
pluginPickMode?: boolean,
remoteName?: string
) {
super(app);
this.result = diff;
this.filename = filename;
@@ -41,9 +48,6 @@ export class ConflictResolveModal extends Modal {
this.remoteName = `${remoteName || "Remote"}`;
this.localName = "Local";
}
// Send cancel signal for the previous merge dialogue
// if not there, simply be ignored.
// sendValue("close-resolve-conflict:" + this.filename, false);
}
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
@@ -94,18 +98,19 @@ export class ConflictResolveModal extends Modal {
override onOpen() {
const { contentEl } = this;
// Send cancel signal for the previous merge dialogue
// if not there, simply be ignored.
globalSlipBoard.submit("conflict-resolved", this.filename, CANCELLED);
if (this.offEvent) {
this.offEvent();
}
this.offEvent = eventHub.onEvent("conflict-cancelled", (path) => {
// Cancel an older dialogue for this path before subscribing this
// instance. Emitting after subscription would close the replacement
// itself; the instance-owned result promise then completes the older
// caller even when it only begins waiting after this event.
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
if (path === this.filename) {
this.sendResponse(CANCELLED);
}
});
// sendValue("close-resolve-conflict:" + this.filename, false);
this.titleEl.setText(this.title);
contentEl.empty();
const diffOptionsRow = contentEl.createDiv("");
@@ -153,23 +158,24 @@ export class ConflictResolveModal extends Modal {
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
this.appendVersionInfo(div2, "deleted", this.localName, date1);
this.appendVersionInfo(div2, "added", this.remoteName, date2);
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => {
const actionContainer = contentEl.createDiv("conflict-action-container");
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
});
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
});
if (!this.pluginPickMode) {
contentEl.createEl("button", { text: "Concat both" }, (e) => {
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
});
}
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(CANCELLED));
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
});
if (diffLength > 100 * 1024) {
this.diffView.empty();
@@ -194,12 +200,10 @@ export class ConflictResolveModal extends Modal {
return;
}
this.consumed = true;
globalSlipBoard.submit("conflict-resolved", this.filename, this.response);
this.resultPromise.resolve(this.response);
}
async waitForResult(): Promise<MergeDialogResult> {
await delay(100);
const r = await globalSlipBoard.awaitNext("conflict-resolved", this.filename);
return r;
return await this.resultPromise.promise;
}
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/deps.ts", () => ({
App: class App {},
Modal: class Modal {
private createElement(): Record<string, unknown> {
const element: Record<string, unknown> = {
addClass: vi.fn(),
addEventListener: vi.fn(),
appendText: vi.fn(),
classList: {
add: vi.fn(),
remove: vi.fn(),
},
empty: vi.fn(),
querySelector: vi.fn(() => null),
querySelectorAll: vi.fn(() => []),
scrollIntoView: vi.fn(),
setText: vi.fn(),
};
element.createDiv = vi.fn(() => this.createElement());
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
const child = this.createElement();
callback?.(child);
return child;
});
element.createSpan = vi.fn(() => this.createElement());
return element;
}
contentEl = this.createElement();
titleEl = {
setText: vi.fn(),
};
close() {
(this as { onClose?: () => void }).onClose?.();
}
},
}));
const conflict: diff_result = {
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
diff: [],
};
describe("ConflictResolveModal result lifecycle", () => {
it("returns a response which closes the dialogue before the caller begins waiting", async () => {
const modal = new ConflictResolveModal({} as never, "early-response.md" as FilePathWithPrefix, conflict);
modal.sendResponse(POSTPONED);
const result = await Promise.race([
modal.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
]);
expect(result).toBe(POSTPONED);
});
it("cancels the previous same-path dialogue without cancelling the replacement", async () => {
const filename = "same-path.md" as FilePathWithPrefix;
const previous = new ConflictResolveModal({} as never, filename, conflict);
const replacement = new ConflictResolveModal({} as never, filename, conflict);
previous.onOpen();
replacement.onOpen();
const previousResult = await Promise.race([
previous.waitForResult(),
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
]);
const replacementState = await Promise.race([
replacement.waitForResult(),
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
]);
previous.sendResponse(CANCELLED);
replacement.sendResponse(CANCELLED);
expect(previousResult).toBe(CANCELLED);
expect(replacementState).toBe("still-open");
});
});
@@ -9,15 +9,75 @@ import {
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConflictResolveModal } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
import { displayRev } from "@/common/utils.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import { serialized } from "octagonal-wheels/concurrency/lock";
import type { LiveSyncCore } from "@/main.ts";
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
import { $msg } from "@/common/translation.ts";
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
try {
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
return conflictCount === 0 ? 0 : conflictCount + 1;
} catch (error) {
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
this._log(error, LOG_LEVEL_VERBOSE);
return undefined;
}
}
private async getActiveConflictMessages(): Promise<string[]> {
const filename = this.services.vault.getActiveFilePath();
if (!filename) return [];
const versionCount = await this.getConflictVersionCount(filename);
if (versionCount === 0) {
this.postponedConflictEpisodes.delete(filename);
return [];
}
if (versionCount !== undefined && versionCount >= 3) {
return [
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
COUNT: `${versionCount}`,
}),
];
}
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
return [$msg("This file has unresolved conflicts.")];
}
return [];
}
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
if ((await this.getConflictVersionCount(filename)) === 0) {
this.postponedConflictEpisodes.delete(filename);
}
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
this.postponedConflictEpisodes.delete(filename);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
await this.services.conflict.queueCheckFor(filename);
await this.services.conflict.ensureAllProcessed();
}
_everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-checkdoc-conflicted",
name: "Resolve if conflicted.",
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
const file = view.file;
if (!file) return;
void this.requestConflictResolution(file.path as FilePathWithPrefix);
},
});
this.addCommand({
id: "livesync-conflictcheck",
name: "Pick a file to resolve conflict",
@@ -38,10 +98,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
// UI for resolving conflicts should one-by-one.
return await serialized(`conflict-resolve-ui`, async () => {
if (this.postponedConflictEpisodes.has(filename)) {
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
return false;
}
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
dialog.open();
const selected = await dialog.waitForResult();
if (selected === POSTPONED) {
this.postponedConflictEpisodes.add(filename);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
return false;
}
if (selected === CANCELLED) {
// Cancelled by UI, or another conflict.
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
@@ -52,8 +123,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
return false;
}
if (!testDoc._conflicts) {
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
await this.refreshConflictState(filename);
return false;
}
if (
testDoc._rev !== conflictCheckResult.left.rev ||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
) {
this._log(
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
LOG_LEVEL_INFO
);
await this.refreshConflictState(filename);
await this.services.conflict.queueCheckFor(filename);
return false;
}
const toDelete = selected;
@@ -62,7 +146,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
// Concatenate both conflicted revisions.
// Create a new file by concatenating both conflicted revisions.
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
const delRev = testDoc._conflicts[0];
const delRev = conflictCheckResult.right.rev;
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
return false;
@@ -78,7 +162,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
);
return false;
}
} else if (typeof toDelete === "string") {
} else if (
typeof toDelete === "string" &&
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
) {
// Select one of the conflicted revision to delete.
if (
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
@@ -88,7 +175,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
return false;
}
} else {
this._log(`Merge: Something went wrong: ${filename}, (${toDelete as string})`, LOG_LEVEL_NOTICE);
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
return false;
}
// In here, some merge has been processed.
@@ -103,10 +190,13 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
});
}
async allConflictCheck() {
while (await this.pickFileForResolve());
let notifyIfEmpty = true;
while (await this.pickFileForResolve(notifyIfEmpty)) {
notifyIfEmpty = false;
}
}
async pickFileForResolve() {
async pickFileForResolve(notifyIfEmpty = true) {
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (!("_conflicts" in doc)) continue;
@@ -120,14 +210,15 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
notes.sort((a, b) => b.mtime - a.mtime);
const notesList = notes.map((e) => e.dispPath);
if (notesList.length == 0) {
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
if (notifyIfEmpty) {
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
}
return false;
}
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
if (target) {
const targetItem = notes.find((e) => e.dispPath == target)!;
await this.services.conflict.queueCheckFor(targetItem.path);
await this.services.conflict.ensureAllProcessed();
await this.requestConflictResolution(targetItem.path);
return true;
}
return false;
@@ -172,6 +263,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
fireAndForget(() => this.refreshConflictState(filename));
});
}
}
@@ -0,0 +1,281 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTO_MERGED,
CANCELLED,
DEFAULT_SETTINGS,
LEAVE_TO_SUBSEQUENT,
LOG_LEVEL_NOTICE,
type FilePathWithPrefix,
type diff_result,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const modalState = vi.hoisted(() => ({
constructed: 0,
result: undefined as unknown,
postponed: Symbol("postponed"),
}));
vi.mock("@/common/utils.ts", () => ({
displayRev: (revision: string) => revision,
}));
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
POSTPONED: modalState.postponed,
ConflictResolveModal: class ConflictResolveModal {
constructor() {
modalState.constructed++;
}
open() {}
async waitForResult() {
return modalState.result;
}
},
}));
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
const path = "note.md" as FilePathWithPrefix;
const conflict: diff_result = {
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
diff: [],
};
async function* documents(items: unknown[]) {
for (const item of items) {
yield item;
}
}
function createModule(conflictedRevisions: string[] = ["2-right"]) {
const handlers = {
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
appLifecycle: {
getUnresolvedMessages: {
addHandler: vi.fn((handler: () => Promise<string[]>) => {
handlers.unresolvedMessages = handler;
}),
},
onScanningStartupIssues: { addHandler: vi.fn() },
onInitialise: { addHandler: vi.fn() },
isSuspended: vi.fn(() => false),
},
conflict: {
resolveByUserInteraction: { addHandler: vi.fn() },
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
queueCheckFor: vi.fn(async () => undefined),
ensureAllProcessed: vi.fn(async () => true),
},
replication: { replicateByEvent: vi.fn(async () => true) },
vault: { getActiveFilePath: vi.fn(() => path) },
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
};
const core = {
_services: services,
services,
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
localDatabase: {
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
findAllDocs: vi.fn(() => documents([])),
},
databaseFileAccess: {
getConflictedRevs: vi.fn(async () => conflictedRevisions),
storeContent: vi.fn(async () => true),
},
confirm: {
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
},
};
const plugin = { app: {} };
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
module._log = vi.fn();
return { core, handlers, module, services };
}
describe("ModuleInteractiveConflictResolver postponement", () => {
beforeEach(() => {
modalState.constructed = 0;
modalState.result = modalState.postponed;
});
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
const { module } = createModule();
await module._anyResolveConflictByUI(path, conflict);
await module._anyResolveConflictByUI(path, conflict);
expect(modalState.constructed).toBe(1);
});
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
const { module } = createModule();
modalState.result = CANCELLED;
await module._anyResolveConflictByUI(path, conflict);
await module._anyResolveConflictByUI(path, conflict);
expect(modalState.constructed).toBe(2);
});
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
const { module, services } = createModule();
await module._anyResolveConflictByUI(path, conflict);
await (module as any).requestConflictResolution(path);
await module._anyResolveConflictByUI(path, conflict);
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
expect(modalState.constructed).toBe(2);
});
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { module } = createModule(conflictedRevisions);
await module._anyResolveConflictByUI(path, conflict);
conflictedRevisions.splice(0);
await (module as any).refreshConflictState(path);
conflictedRevisions.push("4-later");
await module._anyResolveConflictByUI(path, conflict);
expect(modalState.constructed).toBe(2);
});
it("contributes the active conflict to the existing unresolved-message display", async () => {
const { core, handlers, module, services } = createModule();
module.onBindFunction(core as never, services as never);
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
});
it("removes the active warning once the conflict has resolved", async () => {
const conflictedRevisions = ["2-right"];
const { core, handlers, module, services } = createModule(conflictedRevisions);
module.onBindFunction(core as never, services as never);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.splice(0);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
});
it("reports the number of live versions and reduces it after each resolved pair", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const { core, handlers, module, services } = createModule(conflictedRevisions);
module.onBindFunction(core as never, services as never);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
]);
conflictedRevisions.shift();
await (module as any).refreshConflictState(path);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
conflictedRevisions.shift();
await (module as any).refreshConflictState(path);
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
});
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
const conflictedRevisions = ["2-second", "2-third"];
const firstSession = createModule(conflictedRevisions);
await firstSession.module._anyResolveConflictByUI(path, conflict);
conflictedRevisions.shift();
const restartedSession = createModule(conflictedRevisions);
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
"This file has unresolved conflicts.",
]);
await restartedSession.module._anyResolveConflictByUI(path, {
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
diff: [],
});
expect(modalState.constructed).toBe(2);
});
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
modalState.result = LEAVE_TO_SUBSEQUENT;
core.localDatabase.getDBEntry.mockResolvedValue({
_rev: "2-left",
_conflicts: ["2-unrelated", "2-right"],
});
await module._anyResolveConflictByUI(path, conflict);
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
});
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
const { core, module, services } = createModule(["2-other"]);
modalState.result = "2-right";
core.localDatabase.getDBEntry.mockResolvedValue({
_rev: "3-new-winner",
_conflicts: ["2-other"],
});
await module._anyResolveConflictByUI(path, conflict);
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
});
});
describe("ModuleInteractiveConflictResolver file selection", () => {
beforeEach(() => {
modalState.constructed = 0;
modalState.result = modalState.postponed;
});
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
const { core, module } = createModule();
core.localDatabase.findAllDocs
.mockImplementationOnce(() =>
documents([
{
_id: "note-id",
_rev: "2-left",
_conflicts: ["2-right"],
path,
mtime: 2,
},
])
)
.mockImplementationOnce(() => documents([]));
core.confirm.askSelectString.mockResolvedValue(path);
await module.allConflictCheck();
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
});
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
const { module } = createModule();
await module.pickFileForResolve();
expect(module._log).toHaveBeenCalledTimes(1);
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
});
});

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