Merge current main and Maintenance keys into settings localisation

This commit is contained in:
vorotamoroz
2026-10-13 11:01:31 +00:00
138 changed files with 12767 additions and 1856 deletions
@@ -419,6 +419,38 @@ describe("runCommand abnormal cases", () => {
expect(appliedSettings.useIndexedDBAdapter).toBe(false);
});
it("setup opens Ephemeral in its window and leaves settings untouched afterwards", async () => {
const end = Date.parse("2026-10-01T00:00:00Z");
const clock = vi.spyOn(Date, "now").mockReturnValue(end - 1_000);
const passphrase = "time-bound-passphrase";
try {
const { uri, usableUntil } = await processSetting.encodeTimeBoundSetupURI(
{ ...DEFAULT_SETTINGS, isConfigured: true, couchDB_DBNAME: "time-bound-vault" },
passphrase
);
expect(usableUntil).toBe(end);
const inWindow = createCoreMock();
inWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase);
await runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: inWindow });
expect(inWindow.services.setting.applyExternalSettings).toHaveBeenCalledWith(
expect.objectContaining({ couchDB_DBNAME: "time-bound-vault" }),
true
);
clock.mockReturnValue(end);
const outOfWindow = createCoreMock();
outOfWindow.services.context.standardIo.prompt.mockResolvedValue(passphrase);
await expect(runCommand(makeOptions("setup", [uri.trim()]), { ...context, core: outOfWindow })).rejects.toThrow(
"Cannot open Setup URI"
);
expect(outOfWindow.services.setting.applyExternalSettings).not.toHaveBeenCalled();
expect(outOfWindow.services.control.applySettings).not.toHaveBeenCalled();
} finally {
clock.mockRestore();
}
});
it("setup imports managed TURN through the existing encrypted URI", async () => {
const core = createCoreMock();
const profiles = {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.30-cli",
"version": "1.0.35-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
@@ -53,6 +53,6 @@
"devDependencies": {
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
"vitest": "^4.1.11"
}
}
+1 -1
View File
@@ -628,7 +628,7 @@ export async function startP2pRelay(): Promise<void> {
//TODO: port mapping should be configurable.
"4000:7777",
"--tmpfs",
"/app/strfry-db:rw,size=256m",
"/app/strfry-db:rw,size=256m,mode=1777",
"--entrypoint",
"sh",
P2P_RELAY_IMAGE,
+16 -8
View File
@@ -13,7 +13,11 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
* Generate a full setup URI from a settings file via the Commonlib package API.
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
*/
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
export async function generateSetupUriFromSettings(
settingsFile: string,
setupPassphrase: string,
preserveRemoteSettings = false
): Promise<string> {
const script = [
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
@@ -21,13 +25,17 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
" const settingsPath = process.env.SETTINGS_FILE;",
" const passphrase = process.env.SETUP_PASSPHRASE;",
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
" settings.couchDB_USER = 'dummy';",
" settings.couchDB_PASSWORD = 'dummy';",
" settings.liveSync = false;",
" settings.syncOnStart = false;",
" settings.syncOnSave = false;",
...(preserveRemoteSettings
? []
: [
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
" settings.couchDB_USER = 'dummy';",
" settings.couchDB_PASSWORD = 'dummy';",
" settings.liveSync = false;",
" settings.syncOnStart = false;",
" settings.syncOnSave = false;",
]),
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
" process.stdout.write(uri.trim());",
"})();",
+79 -3
View File
@@ -1,6 +1,11 @@
import { assert } from "@std/assert";
import { TempDir } from "./helpers/temp.ts";
import { initSettingsFile, applyP2pSettings, applyP2pTestTweaks } from "./helpers/settings.ts";
import {
initSettingsFile,
applyP2pSettings,
applyP2pTestTweaks,
generateSetupUriFromSettings,
} from "./helpers/settings.ts";
import { startCliInBackground } from "./helpers/backgroundCli.ts";
import {
discoverPeer,
@@ -9,10 +14,10 @@ import {
maybeStartCoturn,
stopCoturnIfStarted,
} from "./helpers/p2p.ts";
import { runCli } from "./helpers/cli.ts";
import { runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
import { getOptimalLoopbackIp } from "./helpers/net.ts";
Deno.test("p2p-sync: discovers peer and completes sync", async () => {
Deno.test("p2p-sync: transfers with the same ID key and rejects a different document ID key", async () => {
const loopbackIp = await getOptimalLoopbackIp();
const loopbackHost = loopbackIp === "::1" ? "[::1]" : loopbackIp;
@@ -32,14 +37,18 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
const hostSettings = workDir.join("settings-host.json");
const clientVault = workDir.join("vault-sync");
const clientSettings = workDir.join("settings-sync.json");
const rejectedVault = workDir.join("vault-rejected");
const rejectedSettings = workDir.join("settings-rejected.json");
await Deno.mkdir(hostVault, { recursive: true });
await Deno.mkdir(clientVault, { recursive: true });
await Deno.mkdir(rejectedVault, { recursive: true });
const relayStarted = await maybeStartLocalRelay(relay);
const coturnStarted = await maybeStartCoturn(turnServers);
try {
await initSettingsFile(hostSettings);
await initSettingsFile(clientSettings);
await initSettingsFile(rejectedSettings);
await applyP2pSettings(
hostSettings,
roomId,
@@ -58,8 +67,52 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
"~.*",
turnServers
);
await applyP2pSettings(
rejectedSettings,
roomId,
passphrase,
"self-hosted-livesync-cli-tests",
relay,
"~.*",
turnServers
);
await applyP2pTestTweaks(hostSettings, hostPeerName, passphrase);
await applyP2pTestTweaks(clientSettings, clientPeerName, passphrase);
await applyP2pTestTweaks(rejectedSettings, "p2p-rejected-" + nonce, passphrase);
for (const [vault, path, key, label] of [
[hostVault, hostSettings, "ab".repeat(32), "host"],
[clientVault, clientSettings, "ab".repeat(32), "client"],
[rejectedVault, rejectedSettings, "cd".repeat(32), "rejected"],
]) {
const settings = JSON.parse(await Deno.readTextFile(path));
settings.idDerivationVersion = 1;
settings.idDerivationKey = key;
const sourcePath = workDir.join("setup-source-" + label + ".json");
await Deno.writeTextFile(sourcePath, JSON.stringify(settings));
const setupPassphrase = "independent-id-setup-passphrase";
const setupUri = await generateSetupUriFromSettings(sourcePath, setupPassphrase, true);
await runCliWithInputOrFail(setupPassphrase + "\n", vault, "--settings", path, "setup", setupUri);
const persisted = JSON.parse(await Deno.readTextFile(path));
assert(persisted.idDerivationVersion === 1, "The Setup URI lost the ID derivation version.");
assert(persisted.idDerivationKey === "", "The CLI stored the ID key in plain text.");
assert(
typeof persisted.encryptedIdDerivationKey === "string" && persisted.encryptedIdDerivationKey.length > 0,
"The CLI did not encrypt the saved ID key."
);
assert(persisted.P2P_Enabled === true, "The Setup URI disabled P2P.");
assert(persisted.P2P_roomID === roomId, "The Setup URI changed the P2P room.");
assert(persisted.P2P_relays === relay, "The Setup URI changed the P2P relay.");
assert(persisted.remoteType === "ONLY_P2P", "The Setup URI changed the remote type.");
}
const notePath = "p2p/independent-id-note.md";
await runCliWithInputOrFail(
"A note transferred with the saved ID key.\n",
clientVault,
"--settings",
clientSettings,
"put",
notePath
);
const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host");
try {
@@ -82,9 +135,32 @@ Deno.test("p2p-sync: discovers peer and completes sync", async () => {
syncResult.code === 0,
`p2p-sync failed\nstdout: ${syncResult.stdout}\nstderr: ${syncResult.stderr}`
);
const rejectedPeer = await discoverPeer(rejectedVault, rejectedSettings, peersTimeout, hostPeerName);
const rejectedSync = await runCli(
rejectedVault,
"--settings",
rejectedSettings,
"p2p-sync",
rejectedPeer.id,
String(syncTimeout)
);
assert(
rejectedSync.code !== 0,
`P2P accepted a different key for obfuscated document IDs.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
assert(
rejectedSync.combined.includes("Tweak values are not matched"),
`P2P failed before checking peer settings.\nstdout: ${rejectedSync.stdout}\nstderr: ${rejectedSync.stderr}`
);
} finally {
await host.stop();
}
const received = sanitiseCatStdout(
await runCliOrFail(hostVault, "--settings", hostSettings, "cat", notePath)
).trimEnd();
assert(received === "A note transferred with the saved ID key.", "The host did not receive the keyed note.");
const rejectedRead = await runCli(rejectedVault, "--settings", rejectedSettings, "cat", notePath);
assert(rejectedRead.code !== 0, "The rejected device received the keyed note.");
} finally {
await stopLocalRelayIfStarted(relayStarted);
await stopCoturnIfStarted(coturnStarted);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.30-webapp",
"version": "1.0.35-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.30-webpeer",
"version": "1.0.35-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+71 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import type { P2PServerInfo } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
import qrcode from "qrcode-generator";
import { onDestroy, tick } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import { isTimeBoundSetupURIUsableNow } from "@vrtmrz/livesync-commonlib/setup-uri";
import {
generateP2PCheckSetup,
@@ -106,6 +107,7 @@
let elapsedMilliseconds = $state(0);
let copied = $state<"uri" | "passphrase">();
let copyError = $state("");
let currentTime = $state(Date.now());
let freshCheckStarting = $state(false);
let additionalDeviceAttempt = $state<AdditionalDeviceAttempt>();
@@ -132,6 +134,12 @@
);
let elapsedSeconds = $derived(Math.floor(elapsedMilliseconds / 1_000));
let targetLabel = $derived(target === "desktop" ? "desktop" : "mobile");
let setupURIUsable = $derived(
setup !== undefined && currentTime >= 0 && isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil)
);
let remainingMinutes = $derived(
setup ? Math.max(0, Math.ceil((setup.setupURIUsableUntil - currentTime) / 60_000)) : 0
);
let additionalElapsedMilliseconds = $derived(
additionalDeviceAttempt
? Math.max(
@@ -178,6 +186,7 @@
const generated = await generateP2PCheckSetup(target, { relay });
qrDataURL = createQRCodeDataURL(generated.setupURI);
setup = generated;
currentTime = Date.now();
} catch (error) {
preparationError = formatError(error);
} finally {
@@ -215,6 +224,11 @@
async function copyText(value: string, kind: "uri" | "passphrase"): Promise<void> {
copyError = "";
if (kind === "uri" && (!setup || !isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil))) {
currentTime = Date.now();
copyError = "This Setup URI is outside its time window. Start a fresh check.";
return;
}
try {
await navigator.clipboard.writeText(value);
copied = kind;
@@ -254,6 +268,7 @@
async function startAdditionalDeviceAttempt(): Promise<void> {
if (
!setupURIUsable ||
!monitorActive ||
outcome !== "connected" ||
activeConnections === 0 ||
@@ -268,6 +283,38 @@
await showSetupQRCode();
}
function formatSetupURIEnd(usableUntil: number): string {
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
}).format(new Date(usableUntil));
}
function openSetupURI(event: MouseEvent): void {
if (!setup || !isTimeBoundSetupURIUsableNow(setup.setupURIUsableUntil)) {
event.preventDefault();
currentTime = Date.now();
}
}
onMount(() => {
const refresh = () => (currentTime = Date.now());
const timer = setInterval(refresh, 1_000);
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
clearInterval(timer);
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
});
onDestroy(() => {
if (elapsedTimer !== undefined) {
clearInterval(elapsedTimer);
@@ -372,6 +419,7 @@
<div class="setup-grid">
<div class="qr-panel">
{#if setupURIUsable}
<img
src={qrDataURL}
alt={additionalDeviceAttempt
@@ -383,6 +431,12 @@
? "This is the original encrypted Setup URI; it was not regenerated."
: "QR contains the encrypted Setup URI only."}
</p>
{:else}
<p role="alert">This Setup URI is outside its time window. Start a fresh check to set up another device. A device that already imported it can still be monitored below.</p>
<button type="button" onclick={startFreshCheck} disabled={freshCheckStarting}>
{freshCheckStarting ? "Starting…" : "Start a fresh check"}
</button>
{/if}
</div>
<div class="credential-panel">
@@ -401,6 +455,16 @@
</div>
<p class="field-help">Type this when LiveSync asks to decrypt the Setup URI.</p>
<p class="field-help">
{#if setupURIUsable}
Ephemeral Setup URI: usable until {formatSetupURIEnd(setup.setupURIUsableUntil)}
({remainingMinutes < 1 ? "less than 1 minute" : `${remainingMinutes} minutes`} remaining).
{:else}
This Setup URI is outside its time window. Generate a new one before sharing it.
{/if}
</p>
{#if setupURIUsable}
<label for="setup-uri">Setup URI</label>
<textarea
id="setup-uri"
@@ -414,8 +478,9 @@
<button type="button" onclick={() => copyText(setup!.setupURI, "uri")}>
{copied === "uri" ? "Copied URI" : "Copy Setup URI"}
</button>
<a class="button-link" href={setup.setupURI}>Open in Obsidian</a>
<a class="button-link" href={setup.setupURI} onclick={openSetupURI}>Open in Obsidian</a>
</div>
{/if}
{#if copyError}
<p class="inline-error" role="alert">{copyError}</p>
{/if}
@@ -550,9 +615,11 @@
class="primary-action"
type="button"
onclick={startAdditionalDeviceAttempt}
disabled={activeConnections === 0}
disabled={!setupURIUsable || activeConnections === 0}
>
{activeConnections === 0
{!setupURIUsable
? "Setup URI window ended; start a fresh check"
: activeConnections === 0
? "Waiting for the first device to reconnect…"
: "Try another device without resetting"}
</button>
+15 -4
View File
@@ -1,4 +1,4 @@
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import {
P2P_DEFAULT_SETTINGS,
@@ -17,6 +17,7 @@ export type P2PCheckTarget = "desktop" | "mobile";
export interface GeneratedP2PCheckSetup {
readonly target: P2PCheckTarget;
readonly setupURI: string;
readonly setupURIUsableUntil: number;
readonly setupPassphrase: string;
readonly groupId: string;
readonly relay: string;
@@ -147,16 +148,26 @@ export async function generateP2PCheckSetup(
browserSettings.suspendParseReplicationResult = true;
const setupPassphrase = generateSetupPassphrase();
const setupURI = await encodeSettingsToSetupURI(
const { uri: setupURI, usableUntil } = await encodeTimeBoundSetupURI(
deviceSettings,
setupPassphrase,
["pluginSyncExtendedSetting", "doNotUseFixedRevisionForChunks", "P2P_DevicePeerName", "deviceAndVaultName"],
true
{
mode: "ephemeral",
removeProperties: [
"pluginSyncExtendedSetting",
"doNotUseFixedRevisionForChunks",
"P2P_DevicePeerName",
"deviceAndVaultName",
],
skipDefaultValue: true,
}
);
if (usableUntil === null) throw new Error("Ephemeral Setup URI has no time window");
return {
target,
setupURI: setupURI.trim(),
setupURIUsableUntil: usableUntil,
setupPassphrase,
groupId: credentials.groupId,
relay: deviceSettings.P2P_relays,
+5 -16
View File
@@ -8,7 +8,7 @@ export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-s
export const COMPATIBILITY_PAUSE_SETTING_MESSAGE =
"Remote synchronisation is paused until this device's compatibility review has been completed.";
export type DatabaseCompatibilityVersionState = "missing" | "invalid" | "upgrade" | "downgrade";
export type DatabaseCompatibilityVersionState = "invalid" | "upgrade" | "downgrade";
export interface DatabaseCompatibilityReason {
source: "database-version";
@@ -57,18 +57,9 @@ export interface CompatibilityEvaluationInput {
function databaseVersionReason(
acknowledgedVersion: string | null,
currentVersion: number,
isNewVault: boolean
currentVersion: number
): DatabaseCompatibilityReason | undefined {
if (acknowledgedVersion === null || acknowledgedVersion === "") {
if (isNewVault) return undefined;
return {
source: "database-version",
state: "missing",
currentVersion,
resumable: true,
};
}
if (acknowledgedVersion === null || acknowledgedVersion === "") return undefined;
const parsed = Number(acknowledgedVersion);
if (!Number.isSafeInteger(parsed)) {
@@ -104,9 +95,8 @@ function databaseVersionReason(
* The caller owns persistence, user interaction, and the actual replication gate.
*/
export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput): CompatibilityEvaluation {
const isNewVault = input.migrationState?.isNewVault === true;
const reasons: CompatibilityPauseReason[] = [];
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion, isNewVault);
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion);
if (databaseReason) reasons.push(databaseReason);
if (input.migrationState?.requiresSyncReview === true) {
@@ -130,8 +120,7 @@ export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput):
if (reasons.length === 0) {
return {
initialiseAcknowledgedVersion:
isNewVault && (input.acknowledgedVersion === null || input.acknowledgedVersion === ""),
initialiseAcknowledgedVersion: input.acknowledgedVersion === null || input.acknowledgedVersion === "",
};
}
+82 -48
View File
@@ -68,8 +68,32 @@ describe("database compatibility evaluation", () => {
});
});
it("requires review when an existing Vault has no valid acknowledged version", () => {
for (const acknowledgedVersion of [null, "invalid"]) {
it.each([null, ""])(
"initialises a missing marker (%s) without pausing an existing Vault",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result).toEqual({ initialiseAcknowledgedVersion: true });
}
);
it("initialises a missing marker when no settings migration state is available", () => {
expect(
evaluateCompatibilityPause({
acknowledgedVersion: null,
currentVersion: 12,
legacyReviewMessage: "",
})
).toEqual({ initialiseAcknowledgedVersion: true });
});
it("requires review when an existing Vault has an invalid acknowledged version", () => {
for (const acknowledgedVersion of ["invalid", "NaN", "12.5"]) {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
@@ -77,39 +101,44 @@ describe("database compatibility evaluation", () => {
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(true);
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version" });
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version", state: "invalid" });
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
});
it("does not permit a future settings schema to be acknowledged by an older implementation", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
targetVersion: 2,
isFromFutureSchema: true,
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
it.each(["12", null])(
"does not permit a future settings schema with marker %s to be acknowledged",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
currentVersion: 2,
targetVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
});
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
it("retains a settings migration review in the host compatibility reason", () => {
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
sourceVersion: 3,
currentVersion: 2,
isFromFutureSchema: true,
resumable: false,
reviewReasons: [],
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
);
it.each(["12", null])("retains a settings migration review with marker %s", (acknowledgedVersion) => {
const reviewReasons = [
{
code: "legacy-update-review-pending",
@@ -118,7 +147,7 @@ describe("database compatibility evaluation", () => {
},
];
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 9,
@@ -137,27 +166,32 @@ describe("database compatibility evaluation", () => {
resumable: true,
reviewReasons,
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
});
it("compatibility: retains an earlier unstructured review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
it.each(["12", null])(
"compatibility: retains an earlier unstructured review with marker %s",
(acknowledgedVersion) => {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
}
);
it("compatibility: scopes the earlier review marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"⚠️ Initialise or overwrite the remote": "⚠️ Initialise or overwrite the remote",
"🔗 Join this device": "🔗 Join this device",
"⚙️ Apply settings only (advanced)": "⚙️ Apply settings only (advanced)",
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.":
"Configure TURN when a direct connection cannot be established or when you select TURN relay only.",
"TURN configuration": "TURN configuration",
@@ -204,6 +207,50 @@ export const liveSyncProvisionalEnglishMessages = {
"Repair failed before the source was removed. Run inspection again before retrying.",
"Connection settings": "Connection settings",
"Saved connections": "Saved connections",
"ID generation": "ID generation",
"Keep current configuration": "Keep current configuration",
"Set an ID key": "Set an ID key",
"Current configuration: a saved ID key is used.": "Current configuration: a saved ID key is used.",
"Current configuration: the saved ID key is retained while E2EE is off.":
"Current configuration: the saved ID key is retained while E2EE is off.",
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.":
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase.",
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.":
"Changing the E2EE passphrase changes IDs generated by the legacy configuration.",
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.":
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically.",
Configured: "Configured",
"The saved ID key is configured. Its source cannot be shown again.":
"The saved ID key is configured. Its source cannot be shown again.",
"Leave this input empty to keep the saved ID key.": "Leave this input empty to keep the saved ID key.",
"Generate a random ID key": "Generate a random ID key",
"How to set the ID key": "How to set the ID key",
"Derive from current E2EE passphrase": "Derive from current E2EE passphrase",
"Enter an ID source": "Enter an ID source",
"Import an ID recovery code": "Import an ID recovery code",
"ID source": "ID source",
"ID recovery code": "ID recovery code",
"Enter an ID recovery code": "Enter an ID recovery code",
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.":
"Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.",
"Paste a tagged recovery code from an existing device to restore the same ID key.":
"Paste a tagged recovery code from an existing device to restore the same ID key.",
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.":
"For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.",
"Show current recovery code": "Show current recovery code",
"Hide current recovery code": "Hide current recovery code",
"Current ID recovery code": "Current ID recovery code",
"Copy recovery code": "Copy recovery code",
"Recovery code copied.": "Recovery code copied.",
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.":
"The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.",
"The recovery code could not be copied. Select and copy the visible code instead.":
"The recovery code could not be copied. Select and copy the visible code instead.",
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.":
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead.",
"An ID source is required to enable this option.": "An ID source is required to enable this option.",
"The ID source or recovery code is invalid. Check it and try again.":
"The ID source or recovery code is invalid. Check it and try again.",
} as const;
export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages;
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -324,19 +324,19 @@
"How would you like to configure the connection to your server?": "How would you like to configure the connection to your server?",
"However, This should not be enabled if you want to increase your secrecy more.": "However, This should not be enabled if you want to increase your secrecy more.",
"I am adding a device to an existing synchronisation setup": "I am adding a device to an existing synchronisation setup",
"I am ready, unlock the database": "I am ready, unlock the database",
"I am setting this up for the first time": "I am setting this up for the first time",
"I am setting up a new server for the first time / I want to reset my existing server.": "I am setting up a new server for the first time / I want to reset my existing server.",
"I am unable to create a backup of my Vault.": "I am unable to create a backup of my Vault.",
"I am unable to create a backup of my Vaults.": "I am unable to create a backup of my Vaults.",
"I have created a backup of my Vault.": "I have created a backup of my Vault.",
"I have made a backup, mark this device as resolved": "I have made a backup, mark this device as resolved",
"I know my server details, let me enter them": "I know my server details, let me enter them",
"I understand that all changes made on other smartphones or computers possibly could be lost.": "I understand that all changes made on other smartphones or computers possibly could be lost.",
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.": "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.",
"I understand that this action is irreversible once performed.": "I understand that this action is irreversible once performed.",
"I understand the risks and will proceed without a backup.": "I understand the risks and will proceed without a backup.",
"I Understand, Overwrite Server": "I Understand, Overwrite Server",
"I'm ready, unlock the database": "I'm ready, unlock the database",
"I've made a backup, mark this device 'resolved'": "I've made a backup, mark this device 'resolved'",
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).",
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.",
@@ -1105,7 +1105,7 @@
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.",
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.",
"The minimum interval for automatic synchronisation on event.": "The minimum interval for automatic synchronisation on event.",
"The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication.": "The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication.",
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.",
"The remote is already set up, and the configuration is compatible (or got compatible by this operation).": "The remote is already set up, and the configuration is compatible (or got compatible by this operation).",
"The Setup-URI does not appear to be valid. Please check that you have copied it correctly.": "The Setup-URI does not appear to be valid. Please check that you have copied it correctly.",
"The Setup-URI is valid and ready to use.": "The Setup-URI is valid and ready to use.",
@@ -1132,7 +1132,7 @@
"This will rebuild the local database on this device using the most recent data from the server. This action is designed to resolve synchronisation inconsistencies and restore correct functionality.": "This will rebuild the local database on this device using the most recent data from the server. This action is designed to resolve synchronisation inconsistencies and restore correct functionality.",
"This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": "This will recreate chunks for all files. If there were missing chunks, this may fix the errors.",
"To minimise the creation of new conflicts": "To minimise the creation of new conflicts",
"To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication": "To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication",
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.",
"Transfer Tweak": "Transfer Tweak",
"TURN Credential": "TURN Credential",
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank.": "TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank.",
@@ -1262,10 +1262,6 @@
"Ui.Settings.Maintenance.Scram": "Scram!",
"Ui.Settings.Maintenance.SendChunks": "Send chunks",
"Ui.Settings.Maintenance.Syncing": "Syncing",
"Ui.Settings.Maintenance.WarningLockedReadyAction": "I am ready, unlock the database",
"Ui.Settings.Maintenance.WarningLockedReadyText": "To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.",
"Ui.Settings.Maintenance.WarningLockedResolveAction": "I have made a backup, mark this device as resolved",
"Ui.Settings.Maintenance.WarningLockedResolveText": "The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.",
"Ui.Settings.Maintenance.WriteRedFlagAndRestart": "Flag and restart",
"Ui.Settings.Patches.CompatibilityConflict": "Compatibility (Conflict Behaviour)",
"Ui.Settings.Patches.CompatibilityDatabase": "Compatibility (Database structure)",
+105 -109
View File
@@ -1,22 +1,22 @@
{
", please select the option that best describes the current state of your Vault. The application will then check your files in the most appropriate way based on your selection.": ", selecciona la opción que mejor describa el estado actual de tu Vault. La aplicación comprobará entonces tus archivos de la forma más adecuada según tu elección.",
", please select the option that best describes the current state of your Vault. The application will then check your files in the most appropriate way based on your selection.": ", selecciona la opción que mejor describa el estado actual de tu bóveda. La aplicación comprobará entonces tus archivos de la forma más adecuada según tu elección.",
"(Active)": "(Activo)",
"(BETA) Always overwrite with a newer file": "(BETA) Sobrescribir siempre con archivo más nuevo",
"(Beta) Use ignore files": "(Beta) Usar archivos de ignorar",
"(Beta) Use ignore files": "(Beta) Usar archivos ignore",
"(Days passed, 0 to disable automatic-deletion)": "(Días transcurridos, 0 para desactivar)",
"(e.g., after editing many files whilst offline)": "(p. ej., tras editar muchos archivos sin conexión)",
"(e.g., after editing many files whilst offline)": "(p. ej., tras editar muchos archivos estando sin conexión)",
"(e.g., immediately after restoring on another computer, or having recovered from a backup)": "(p. ej., justo después de restaurar en otro ordenador o de recuperar una copia de seguridad)",
"(e.g., setting up for the first time on a new smartphone, starting from a clean slate)": "(p. ej., configurando por primera vez un móvil nuevo, empezando de cero)",
"(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": "(Ej: Leer chunks online) Lee chunks directamente en línea. Aumente tamaño de chunks personalizados",
"(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": "(MB) Saltar cambios en archivos locales/remotos mayores a este tamaño. Si se reduce, se usará versión nueva",
"(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.": "(Ej: Leer chunks online) Lee chunks directamente en línea. Aumenta tamaño de chunks personalizados.",
"(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.": "(MB) Si se activa, se saltarán cambios en archivos locales y remotos mayores a este tamaño. Si tamaño del archivo se reduce se usará la versión nueva.",
"(Mega chars)": "(Millones de caracteres)",
"(Missing)": "(Falta)",
"(Not recommended) If set, credentials will be stored in the file.": "(No recomendado) Almacena credenciales en el archivo",
"(Obsolete) Use an old adapter for compatibility": "(Obsoleto) Usar adaptador antiguo",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Déjelo vacío para sincronizar todos los archivos. Defina un filtro como expresión regular para limitar los archivos que se sincronizan.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Si se establece, se omitirá cualquier cambio en archivos locales y remotos que coincida con este patrón.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Seleccione esto si ya utiliza la sincronización en otro ordenador o teléfono). Esta opción es adecuada si desea añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Seleccione esto si está configurando este dispositivo como el primer dispositivo de sincronización). Esta opción es adecuada si es nuevo en LiveSync y desea configurarlo desde cero。",
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Déjalo vacío para sincronizar todos los archivos. Definaeun filtro como expresión regular para limitar los archivos sincronizados.",
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) Si se establece, se omitirá cualquier cambio en los archivos locales y remotos que coincidan con este patrón.",
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.": "(Seleccione esto si ya utilizas la sincronización en otro ordenador o teléfono). Esta opción es adecuada si quieres añadir este dispositivo a una configuración de LiveSync existente。",
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Selecciona esto si estás configurando este dispositivo como el primer dispositivo de sincronización). Esta opción es adecuada si eres nuevo en LiveSync y quieres configurarlo desde cero。",
"↑: Overwrite Remote": "↑: Sobrescribir remoto",
"↓: Overwrite Local": "↓: Sobrescribir local",
"⇅: Use newer": "⇅: Usar el más nuevo",
@@ -34,7 +34,7 @@
"🗑 Delete": "🗑 Eliminar",
"🟢 Connected": "🟢 Conectado",
"${count} issue(s) detected!": "¡${count} problema(s) detectado(s)!",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Un URI de configuración es una única cadena de texto que contiene la dirección del servidor y los datos de autenticación. Si el script de instalación de su servidor generó un URI, usarlo proporciona una configuración sencilla y segura。",
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.": "Un URI de configuración es una única cadena de texto que contiene la dirección del servidor y los datos de autenticación. Si el script de instalación de tu servidor generó un URI, usarla proporciona una configuración sencilla y segura。",
"Accept": "Aceptar",
"Accept in session": "Aceptar en esta sesión",
"ACCEPTED": "ACEPTADO",
@@ -46,25 +46,25 @@
"Active Remote Configuration": "Configuración remota activa",
"Add default patterns": "Añadir patrones predeterminados",
"Add new connection": "Añadir conexión",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Por favor informa de este problema.",
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es muy inesperada. Por favor informa de este problema.",
"Advanced": "Avanzado",
"Advanced Settings": "Ajustes avanzados",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "Tras reiniciar, los datos de este dispositivo se subirán al servidor como «copia maestra». Ten en cuenta que cualquier dato no deseado que haya ahora en el servidor se sobrescribirá por completo.",
"After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised files in this vault, conflicts may occur with the server data.": "Tras reiniciar, la base de datos de este dispositivo se reconstruirá con los datos del servidor. Si hay archivos sin sincronizar en este vault, pueden producirse conflictos con los datos del servidor.",
"After that, synchronise to a brand new vault on each other device with the new remote one by one.": "Después, sincroniza con un vault totalmente nuevo en cada uno de los demás dispositivos, uno por uno, usando el nuevo remoto.",
"After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.": "Tras reiniciar, los datos de este dispositivo se subirán al servidor como «copia maestra». Ten en cuenta que cualquier dato en el servidor se sobrescribirá por completo.",
"After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised files in this vault, conflicts may occur with the server data.": "Tras reiniciar, la base de datos de este dispositivo se reconstruirá con los datos del servidor. Si hay archivos sin sincronizar en esta bóveda pueden producirse conflictos con los datos del servidor.",
"After that, synchronise to a brand new vault on each other device with the new remote one by one.": "Después, sincroniza con una bóveda totalmente nueva en cada uno de los demás dispositivos, uno por uno, usando el nuevo remoto.",
"All checks passed successfully!": "¡Todas las comprobaciones se han superado correctamente!",
"All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": "Todos los dispositivos tienen el mismo valor de progreso (${progress}). Parece que están sincronizados, por lo que se puede continuar con la recolección de basura.",
"All the same or non-existent": "Todo igual o inexistente",
"Allow in session": "Permitir en esta sesión",
"Allow permanently": "Permitir permanentemente",
"Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future.": "Además, si usas sincronización punto a punto, esta configuración se aplicará cuando en el futuro cambies a otros métodos y te conectes a un servidor remoto.",
"Always prompt merge conflicts": "Siempre preguntar en conflictos",
"Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future.": "Además, si usas sincronización punto a punto, esta configuración también se aplicará en el futuro si cambias a otros métodos de sincronización y te conectas a un servidor remoto.",
"Always prompt merge conflicts": "Siempre preguntar cuando haya conflictos",
"Analyse": "Analizar",
"Analyse database usage": "Analizar el uso de la base de datos",
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": "Analiza el uso de la base de datos y genera un informe TSV para que puedas diagnosticarlo tú mismo. Puedes pegar el informe generado en la hoja de cálculo que prefieras.",
"Apply All Selected": "Aplicar todo lo seleccionado",
"Apply Latest Change if Conflicting": "Aplicar último cambio en conflictos",
"Apply Latest Change if Conflicting": "Aplicar último cambio si hay conflictos",
"Apply preset configuration": "Aplicar configuración predefinida",
"Apply the settings": "Aplicar los ajustes",
"Ask a passphrase at every launch": "Solicitar la frase de contraseña en cada inicio",
@@ -75,11 +75,11 @@
"Available Peers": "Pares disponibles",
"Back": "Volver",
"Back to non-configured": "Volver a no configurado",
"Batch database update": "Actualización por lotes de BD",
"Batch database update": "Actualización de base de datos por lotes",
"Batch limit": "Límite de lotes",
"Batch size": "Tamaño de lote",
"Batch size of on-demand fetching": "Tamaño de lote para obtención bajo demanda",
"Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": "Antes de v0.17.16 usábamos adaptador antiguo. Nuevo adaptador requiere reconstruir BD local. Desactive cuando pueda",
"Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.": "Antes de v0.17.16 se usaba el adaptador antiguo. El nuevo adaptador requiere reconstruir la base de datos local. Por favor desactiva esta opción cuando puedas. Si está activa cuando se sincroniza la base de datos se intentará desactivar de nuevo.",
"Broadcasting?": "¿Difusión?",
"Bucket Name": "Nombre del bucket",
"by resetting the remote, you will be informed on other devices.": "al restablecer el remoto, se te avisará en los demás dispositivos.",
@@ -102,7 +102,7 @@
"Comma separated `.gitignore, .dockerignore`": "Separados por comas: `.gitignore, .dockerignore`",
"Command": "Comando",
"Communicating": "Comunicando",
"Compaction in progress on remote database...": "Compactación en curso en la base de datos remota...",
"Compaction in progress on remote database...": "Compactación en curso de la base de datos remota...",
"Compaction on remote database completed successfully.": "La compactación de la base de datos remota se ha completado correctamente.",
"Compaction on remote database failed.": "La compactación de la base de datos remota ha fallado.",
"Compaction on remote database timed out.": "La compactación de la base de datos remota ha superado el tiempo de espera.",
@@ -123,7 +123,7 @@
"Configure And Change Remote": "Configurar y cambiar remoto",
"Configure E2EE": "Configurar E2EE",
"Configure Remote": "Configurar remoto",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure manualmente la misma información del servidor que en sus otros dispositivos. Solo para usuarios muy avanzados。",
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configura manualmente la misma información del servidor que en sus otros dispositivos. Solo para usuarios muy avanzados。",
"Conflict & Deletion Options": "Opciones de conflictos y eliminación",
"Connect": "Conectar",
"Connect to existing database and continue": "Conectar a la base de datos existente y continuar",
@@ -187,9 +187,9 @@
"Device Setup Method": "Método de configuración del dispositivo",
"Devices:": "Dispositivos:",
"Diagnostic RTCPeerConnection is enabled": "El RTCPeerConnection de diagnóstico está habilitado",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display Language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tiene traducciones para tu idioma, así que se ha activado el ajuste %{Display Language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable.btnRevertToDefault": "Mantener %{lang-def}",
"dialog.yourLanguageAvailable.Title": " ¡Hay traducción disponible!",
"dialog.yourLanguageAvailable.Title": " ¡Hay una traducción disponible!",
"Diff": "Diferencias",
"Different": "Distinto",
"Disables all synchronization and restart.": "Desactiva toda la sincronización y reinicia la aplicación.",
@@ -208,7 +208,7 @@
"Doctor.Button.No": "No",
"Doctor.Button.Skip": "Dejarlo como está",
"Doctor.Button.Yes": "Sí",
"Doctor.Dialogue.Main": "¡Hola! El Doctor de configuración se ha activado por ${activateReason}.\nPor desgracia, se han detectado algunos ajustes como posibles problemas.\nTranquilo: los resolveremos uno a uno.\n\nPara que lo sepas de antemano, te preguntaremos por los siguientes puntos.\n\n${issues}\n\n¿Empezamos?",
"Doctor.Dialogue.Main": "¡Hola! El Doctor de configuración se ha activado por ${activateReason}.\nPor desgracia, se han detectado algunos ajustes como posibles problemas.\nTranquilo: los resolveremos uno a uno.\n\nPara que lo sepas de antemano, te preguntaremos por lo siguiente.\n\n${issues}\n\n¿Empezamos?",
"Doctor.Dialogue.MainFix": "\n## ${name}\n\n| Actual | Ideal |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**Nivel de recomendación:** ${level}\n\n### ¿Por qué se ha detectado?\n\n${reason}\n\n${note}\n\n¿Ajustarlo al valor ideal?",
"Doctor.Dialogue.Title": "Doctor de configuración de Self-hosted LiveSync",
"Doctor.Dialogue.TitleAlmostDone": "¡Casi hemos terminado!",
@@ -218,8 +218,8 @@
"Doctor.Level.Optional": "Opcional",
"Doctor.Level.Recommended": "Recomendado",
"Doctor.Message.NoIssues": "¡No se ha detectado ningún problema!",
"Doctor.Message.RebuildLocalRequired": "¡Atención! Hay que reconstruir la base de datos local para aplicar esto.",
"Doctor.Message.RebuildRequired": "¡Atención! Hay que reconstruir para aplicar esto.",
"Doctor.Message.RebuildLocalRequired": "¡Atención! Hay que reconstruir la base de datos local",
"Doctor.Message.RebuildRequired": "¡Atención! Hay que reconstruir la base de datos",
"Doctor.Message.SomeSkipped": "Hemos dejado algunos problemas sin resolver. ¿Quieres que te lo pregunte de nuevo en el próximo inicio?",
"Doctor.RULES.E2EE_V02500.REASON": "El cifrado de extremo a extremo es ahora más robusto y más rápido. Además, una nueva revisión del código reveló que el E2EE anterior estaba comprometido, por lo que conviene aplicarlo cuanto antes. Lamentamos de veras las molestias. Este ajuste no es compatible con versiones anteriores: todos los dispositivos sincronizados deben actualizarse a la v0.25.0 o superior. No es necesario reconstruir (los datos se convertirán al nuevo formato durante la transferencia), pero se recomienda hacerlo siempre que sea posible.",
"Document History": "Historial del documento",
@@ -239,8 +239,8 @@
"Enable this if your Object Storage doesn't support CORS": "Habilitar si su almacenamiento no soporta CORS",
"Enable this option to automatically apply the most recent change to documents even when it conflicts": "Aplicar cambios recientes automáticamente aunque generen conflictos",
"Enabled": "Habilitado",
"Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices.": "Activar el cifrado de extremo a extremo garantiza que tus datos se cifren en tu dispositivo antes de enviarse al servidor remoto. Así, aunque alguien acceda al servidor, no podrá leer tus datos sin la frase de contraseña. Recuérdala bien, porque será necesaria para descifrar tus datos en los demás dispositivos.",
"Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": "Cifrar contenido en la base de datos remota. Se recomienda habilitar si usa la sincronización del plugin.",
"Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices.": "Activar el cifrado de extremo a extremo garantiza que tus datos se cifren en tu dispositivo antes de enviarse al servidor remoto. Así, aunque alguien acceda al servidor, no podrá leer tus datos sin la frase de contraseña. Recuérdala bien porque será necesaria para descifrar tus datos en los demás dispositivos.",
"Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": "Cifrar contenido en la base de datos remota. Se recomienda habilitar si usa la sincronización del complemento.",
"Encrypting sensitive configuration items": "Cifrando elementos sensibles",
"Encryption Algorithm": "Algoritmo de cifrado",
"Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": "Frase de cifrado. Si la cambia, sobrescriba la base del servidor con los nuevos archivos cifrados.",
@@ -275,13 +275,13 @@
"Failed to connect to the server. Please check your settings.": "No se pudo conectar al servidor. Revisa tus ajustes.",
"Failed to connect to the signalling relay: ${reason}": "No se pudo conectar al relé de señalización: ${reason}",
"Failed to create replicator instance.": "No se pudo crear la instancia del replicador.",
"Failed to parse Setup-URI.": "No se pudo interpretar el Setup-URI.",
"Failed to parse Setup-URI.": "No se pudo interpretar el Setup URI.",
"Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": "No se pudo iniciar la replicación puntual previa a la recolección de basura. Recolección de basura cancelada.",
"Failed to start replication after Garbage Collection.": "No se pudo iniciar la replicación después de la recolección de basura.",
"Failed:": "Fallidas:",
"Fetch": "Obtener",
"Fetch chunks on demand": "Obtener chunks bajo demanda",
"Fetch database with previous behaviour": "Obtener BD con comportamiento anterior",
"Fetch database with previous behaviour": "Obtener base de datos con comportamiento anterior",
"Fetch remote settings": "Obtener ajustes remotos",
"FETCHING": "OBTENIENDO",
"Fetching status...": "Obteniendo el estado...",
@@ -291,12 +291,12 @@
"Filename": "Nombre de archivo",
"Final Confirmation: Overwrite Server Data with This Device's Files": "Confirmación final: sobrescribir los datos del servidor con los archivos de este dispositivo",
"Finalise the process and resume normal operation": "Finalizar el proceso y reanudar el funcionamiento normal",
"First, please select the option that best describes your current situation.": "Primero, seleccione la opción que describa mejor su situación actual。",
"First, please select the option that best describes your current situation.": "Primero, seleccione la opción que mejor describa tu situación actual。",
"Fix": "Corregir",
"Flag and restart": "Marcar y reiniciar",
"Flagged Selective": "Selectivo marcado",
"Folder Prefix": "Prefijo de carpeta",
"For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.": "Para los algoritmos HS256/HS512, indica la clave secreta compartida. Para ES256/ES512, indica la clave privada en formato PEM pkcs8.",
"For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.": "Indica la clave secreta compartida para los algoritmos HS256/HS512. Para ES256/ES512, indica la clave privada en formato PEM pkcs8.",
"Forces the file to be synced when opened.": "Forzar sincronización al abrir archivo",
"Fresh Start Wipe": "Borrado para reinicio completo",
"Furthermore, if conflicts are already present in the server data, they will be synchronised to this device as they are, and you will need to resolve them locally.": "Además, si ya hay conflictos en los datos del servidor, se sincronizarán tal cual a este dispositivo y tendrás que resolverlos localmente.",
@@ -322,39 +322,39 @@
"How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "¿Cómo se deben gestionar los archivos que se eliminaron en otros dispositivos?\n\n- **${NEWER_CLEANUP}**: elimina los archivos locales si se eliminaron en el remoto.\n Es útil si quieres mantener tu vault limpio y coherente entre dispositivos; asegúrate de tener una copia de seguridad si ya tienes datos importantes en tu vault.\n- **${NEWER_SYNC_ALL}**: vuelve a crear los archivos remotos aunque se hayan eliminado en el remoto.\n Esta opción mantiene todos tus archivos locales, pero puede causar duplicados si hay archivos que existen en local pero no en el remoto. Puedes limpiar esos duplicados manualmente después de sincronizar.",
"How to display network errors when the sync server is unreachable.": "Cómo mostrar los errores de red cuando el servidor de sincronización no está disponible.",
"How to handle extra existing local files?": "¿Cómo gestionar los archivos locales adicionales?",
"How would you like to configure the connection to your server?": "¿Cómo desea configurar la conexión con su servidor?",
"How would you like to configure the connection to your server?": "¿Cómo quieres configurar la conexión con tu servidor?",
"However, This should not be enabled if you want to increase your secrecy more.": "Sin embargo, esto no debería habilitarse si quieres aumentar aún más tu privacidad.",
"I am adding a device to an existing synchronisation setup": "Estoy agregando un dispositivo a una configuración de sincronización existente",
"I am ready, unlock the database": "Estoy listo, desbloquear la base de datos",
"I am setting this up for the first time": "Estoy configurando esto por primera vez",
"I am setting up a new server for the first time / I want to reset my existing server.": "Estoy configurando un servidor nuevo por primera vez / quiero restablecer mi servidor actual.",
"I am unable to create a backup of my Vault.": "No puedo crear una copia de seguridad de mi Vault.",
"I am unable to create a backup of my Vaults.": "No puedo crear una copia de seguridad de mis Vaults.",
"I have created a backup of my Vault.": "He creado una copia de seguridad de mi Vault.",
"I know my server details, let me enter them": "Conozco los datos de mi servidor; permítame introducirlos",
"I am unable to create a backup of my Vault.": "No puedo crear una copia de seguridad de mi bóveda.",
"I am unable to create a backup of my Vaults.": "No puedo crear una copia de seguridad de mis bóvedas.",
"I have created a backup of my Vault.": "He creado una copia de seguridad de mi bóveda.",
"I have made a backup, mark this device as resolved": "He hecho una copia de seguridad, marcar este dispositivo como resuelto",
"I know my server details, let me enter them": "Conozco los datos de mi servidor; déjame introducirlos",
"I understand that all changes made on other smartphones or computers possibly could be lost.": "Entiendo que todos los cambios hechos en otros móviles u ordenadores podrían perderse.",
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.": "Entiendo que los demás dispositivos ya no podrán sincronizar y que habrá que restablecer su información de sincronización.",
"I understand that this action is irreversible once performed.": "Entiendo que esta acción es irreversible una vez realizada.",
"I understand the risks and will proceed without a backup.": "Entiendo los riesgos y continuaré sin copia de seguridad.",
"I Understand, Overwrite Server": "Lo entiendo, sobrescribir el servidor",
"I'm ready, unlock the database": "Estoy listo, desbloquear la base de datos",
"I've made a backup, mark this device 'resolved'": "Ya hice una copia de seguridad, marcar este dispositivo como «resuelto»",
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "Si «Iniciar la conexión P2P automáticamente» está activado, la conexión P2P se iniciará al arrancar el complemento.",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "Si se desactiva, chunks se dividen en hilo UI (comportamiento anterior)",
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "Habilita sincronización eficiente por archivo. Requiere migración y actualizar todos dispositivos a v0.23.18. Pierde compatibilidad con versiones antiguas",
"If \"Auto Start P2P Connection\" is enabled, the P2P connection will be started automatically when the plug-in launches.": "Si «Iniciar la conexión P2P automáticamente» está activada, la conexión P2P se iniciará al arrancar el complemento.",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "Si se desactiva, los chunks se dividen en el hilo de UI (comportamiento anterior)",
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "Habilita sincronización eficiente por archivo. Requiere migración y actualizar todos los dispositivos a v0.23.18. Pierde compatibilidad con versiones antiguas.",
"If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": "Divide chunks en máximo 100 ítems. Menos eficiente en deduplicación",
"If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": "Chunks nuevos se mantienen temporalmente en el documento hasta estabilizarse",
"If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.": "Chunks nuevos se mantienen temporalmente en el documento hasta estabilizarse.",
"If enabled, the ⛔ icon will be shown inside the status instead of the file warnings banner. No details will be shown.": "Si se activa, se mostrará el icono ⛔ en el estado en lugar del banner de advertencia de archivos. No se mostrarán detalles.",
"If enabled, the file under 1kb will be processed in the UI thread.": "Archivos <1kb se procesan en hilo UI",
"If enabled, the file under 1kb will be processed in the UI thread.": "Archivos <1kb se procesan en el hilo de UI",
"If enabled, the notification of hidden files change will be suppressed.": "Si se habilita, se suprimirá la notificación de cambios en archivos ocultos.",
"If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour)": "Si se habilita, todos los chunks se almacenan con la revisión hecha desde su contenido. (comportamiento anterior)",
"If this enabled, All files are handled as case-Sensitive (Previous behaviour).": "Si se habilita, todos los archivos se manejan como sensibles a mayúsculas (comportamiento anterior)",
"If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": "Divide chunks en segmentos semánticos. No todos los sistemas lo soportan",
"If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": "Saltar cambios en archivos locales que coincidan con ignore files. Cambios remotos usan ignore files locales",
"If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": "Mantiene conexión 60s. Si no hay cambios, reinicia socket. Útil con proxies limitantes",
"If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": "Divide chunks en segmentos semánticos. No todos los sistemas lo soportan.",
"If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.": "Saltar cambios en archivos locales que coincidan con ignore files. Cambios remotos usan ignore files locales.",
"If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.": "Mantiene una conexión abierta por 60s en CouchDB. Si no hay cambios, se reinicia socket en vez de dejarlo abierto de forma indefinida. Útil con proxies limitantes pero puede aumentar el uso de recursos.",
"If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate with the CouchDB server. Not compliant with web standards, but works. Note that this might break in future Obsidian versions.": "Si no puedes evitar problemas de CORS, prueba esta opción. Usa la API interna de Obsidian para comunicarse con el servidor CouchDB. No cumple los estándares web, pero funciona. Ten en cuenta que podría dejar de funcionar en versiones futuras de Obsidian.",
"If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate with the S3 server. Not compliant with web standards, but works. Note that this might break in future Obsidian versions.": "Si no puedes evitar problemas de CORS, prueba esta opción. Usa la API interna de Obsidian para comunicarse con el servidor S3. No cumple los estándares web, pero funciona. Ten en cuenta que podría dejar de funcionar en versiones futuras de Obsidian.",
"If you have unsynchronised changes in your Vault on this device, they will likely diverge from the server's versions after the reset. This may result in a large number of file conflicts.": "Si tienes cambios sin sincronizar en el Vault de este dispositivo, es probable que divergan de las versiones del servidor tras el restablecimiento. Esto puede provocar un gran número de conflictos de archivos.",
"If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": "Si alcanzas el límite de tamaño de carga al usar IBM Cloudant, reduce el tamaño de lote y el límite de lote.",
"If you have unsynchronised changes in your Vault on this device, they will likely diverge from the server's versions after the reset. This may result in a large number of file conflicts.": "Si tienes cambios sin sincronizar en la bóveda de este dispositivo, es probable que diverjan de las versiones del servidor tras el restablecimiento. Esto puede provocar un gran número de conflictos de archivos.",
"If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": "Si alcanzas el límite de tamaño de carga al usar IBM Cloudant, por favor reduce el tamaño de lote y el límite de lote.",
"If you understand the risks and still wish to proceed, select so.": "Si entiendes los riesgos y aun así quieres continuar, indícalo.",
"If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here. Otherwise, leave it blank to store data at the root of the bucket.": "Si quieres guardar los datos en una carpeta concreta del bucket, indica aquí un prefijo de carpeta. Si no, déjalo vacío para guardarlos en la raíz del bucket.",
"If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.": "Si quieres usar `LiveSync`, debes difundir los cambios. Todos los pares que estén `observando` y lo detecten iniciarán la replicación para obtenerlos.",
@@ -364,7 +364,7 @@
"Ignore patterns": "Patrones de exclusión",
"Import connection": "Importar conexión",
"IMPORTANT": "IMPORTANTE",
"In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.": "En la mayoría de los casos deberías mantener el algoritmo predeterminado (${algorithm}). Este ajuste solo es necesario si ya tienes un Vault cifrado en otro formato.",
"In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.": "En la mayoría de los casos deberías mantener el algoritmo predeterminado (${algorithm}). Este ajuste solo es necesario si ya tienes una bóveda cifrada en otro formato.",
"In this scenario, Self-hosted LiveSync will recreate metadata for every file and deliberately generate conflicts. Where the file content is identical, these conflicts will be resolved automatically.": "En este caso, Self-hosted LiveSync recreará los metadatos de todos los archivos y generará conflictos a propósito. Cuando el contenido del archivo sea idéntico, esos conflictos se resolverán automáticamente.",
"Incoming:": "Entrantes:",
"Incubate Chunks in Document": "Incubar chunks en documento",
@@ -483,16 +483,16 @@
"moduleInputUIObsidian.defaultTitleSelect": "Seleccionar",
"moduleInputUIObsidian.optionNo": "No",
"moduleInputUIObsidian.optionYes": "Sí",
"moduleLiveSyncMain.logAdditionalSafetyScan": "Escanéo de seguridad adicional...",
"moduleLiveSyncMain.logAdditionalSafetyScan": "Escaneo de seguridad adicional...",
"moduleLiveSyncMain.logLoadingPlugin": "Cargando complemento...",
"moduleLiveSyncMain.logPluginInitCancelled": "La inicialización del complemento fue cancelada por un módulo",
"moduleLiveSyncMain.logPluginVersion": "Self-hosted LiveSync v${manifestVersion} ${packageVersion}",
"moduleLiveSyncMain.logReadChangelog": "LiveSync se ha actualizado, ¡por favor lee el registro de cambios!",
"moduleLiveSyncMain.logSafetyScanCompleted": "Escanéo de seguridad adicional completado",
"moduleLiveSyncMain.logSafetyScanCompleted": "Escaneo de seguridad adicional completado",
"moduleLiveSyncMain.logSafetyScanFailed": "El escaneo de seguridad adicional ha fallado en un módulo",
"moduleLiveSyncMain.logUnloadingPlugin": "Descargando complemento...",
"moduleLiveSyncMain.logVersionUpdate": "LiveSync se ha actualizado, en caso de actualizaciones que rompan, toda la sincronización automática se ha desactivado temporalmente. Asegúrate de que todos los dispositivos estén actualizados antes de habilitar.",
"moduleLiveSyncMain.msgScramEnabled": "Self-hosted LiveSync se ha configurado para ignorar algunos eventos. ¿Es esto correcto?\n\n| Tipo | Estado | Nota |\n|:---:|:---:|---|\n| Eventos de almacenamiento | ${fileWatchingStatus} | Se ignorará cada modificación |\n| Eventos de base de datos | ${parseReplicationStatus} | Cada cambio sincronizado se pospondrá |\n\n¿Quieres reanudarlos y reiniciar Obsidian?\n\n> [!DETAILS]-\n> Estas banderas son establecidas por el complemento mientras se reconstruye o se obtiene. Si el proceso termina de forma anormal, puede mantenerse sin querer.\n> Si no estás seguro, puedes intentar volver a ejecutar estos procesos. Asegúrate de hacer una copia de seguridad de tu bóveda.\n",
"moduleLiveSyncMain.msgScramEnabled": "Self-hosted LiveSync se ha configurado para ignorar algunos eventos. ¿Es esto correcto?\n\n| Tipo | Estado | Nota |\n|:---:|:---:|---|\n| Eventos de almacenamiento | ${fileWatchingStatus} | Se ignorará cada modificación |\n| Eventos de base de datos | ${parseReplicationStatus} | Cada cambio sincronizado se pospondrá |\n\n¿Quieres reanudarlos y reiniciar Obsidian?\n\n> [!DETAILS]-\n> Estas banderas son establecidas por el complemento mientras se reconstruye o se obtiene. Si el proceso termina de forma anormal, puede mantenerse sin querer.\n> Si no estás seguro puedes intentar volver a ejecutar estos procesos. Asegúrate de hacer una copia de seguridad de tu bóveda.\n",
"moduleLiveSyncMain.optionKeepLiveSyncDisabled": "Mantener LiveSync desactivado",
"moduleLiveSyncMain.optionResumeAndRestart": "Reanudar y reiniciar Obsidian",
"moduleLiveSyncMain.titleScramEnabled": "Scram habilitado",
@@ -508,7 +508,7 @@
"moduleMigration.insecureChunkExist.buttons.later": "Lo haré más tarde",
"moduleMigration.insecureChunkExist.buttons.rebuild": "Reconstruir todo",
"moduleMigration.insecureChunkExist.laterMessage": "¡Te recomendamos encarecidamente solucionarlo cuanto antes!",
"moduleMigration.insecureChunkExist.message": "Algunos chunks no se almacenan de forma segura y no están cifrados en las bases de datos.\n**Reconstruye la base de datos para solucionarlo**.\n\nSi tu base de datos remota no está configurada con SSL o usa credenciales poco seguras, **corres el riesgo de exponer datos sensibles**.\n\nNota: actualiza Self-hosted LiveSync a la v0.25.6 o superior en todos tus dispositivos y haz una copia de seguridad fiable de tu vault.\nNota 2: reconstruir todo y obtener los datos consume algo de tiempo y de tráfico; hazlo en horas de poco uso y con una conexión de red estable.\n",
"moduleMigration.insecureChunkExist.message": "Algunos chunks no se almacenan de forma segura y no están cifrados en las bases de datos.\n**Reconstruye la base de datos para solucionarlo**.\n\nSi tu base de datos remota no está configurada con SSL o usa credenciales poco seguras, **corres el riesgo de exponer datos sensibles**.\n\nNota: actualiza Self-hosted LiveSync a la v0.25.6 o superior en todos tus dispositivos y haz una copia de seguridad fiable de tu bóveda.\nNota 2: reconstruir todo y obtener los datos consume algo de tiempo y de tráfico; hazlo en horas de poco uso y con una conexión de red estable.\n",
"moduleMigration.insecureChunkExist.title": "¡Se han encontrado chunks no seguros!",
"moduleMigration.logBulkSendCorrupted": "El envío de fragmentos en bloque se ha habilitado, sin embargo, esta función se ha corrompido. Disculpe las molestias. Deshabilitado automáticamente.",
"moduleMigration.logFetchRemoteTweakFailed": "Error al obtener los valores de ajuste remoto",
@@ -639,7 +639,7 @@
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Establecer chttpd.enable_cors",
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Recomendamos habilitar el cifrado de extremo a extremo y la obfuscación de ruta. ¿Estás seguro de querer continuar sin cifrado?",
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "¿Quieres obtener la configuración del servidor remoto?",
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Si la configuración del servidor no es persistente (por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una vez que puedas conectarte, por favor actualiza las configuraciones en el local.ini del servidor.",
"obsidianLiveSyncSettingTab.msgIfConfigNotPersistent": "Si la configuración del servidor no es persistente (por ejemplo, ejecutándose en Docker), los valores aquí pueden cambiar. Una vez que puedas conectarte, por favor actualiza las configuraciones en el local.ini del servidor.",
"obsidianLiveSyncSettingTab.msgInvalidPassphrase": "Tu frase de contraseña de cifrado podría ser inválida. ¿Estás seguro de querer continuar?",
"obsidianLiveSyncSettingTab.msgNewVersionNote": "¿Aquí debido a una notificación de actualización? Por favor, revise el historial de versiones. Si está satisfecho, haga clic en el botón. Una nueva actualización volverá a mostrar esto.",
"obsidianLiveSyncSettingTab.msgNonHTTPSInfo": "Configurado como URI que no es HTTPS. Ten en cuenta que esto puede no funcionar en dispositivos móviles.",
@@ -672,15 +672,15 @@
"obsidianLiveSyncSettingTab.okAdminPrivileges": "✔ Tienes privilegios de administrador.",
"obsidianLiveSyncSettingTab.okCorsCredentials": "✔ cors.credentials está correcto.",
"obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin": "CORS credenciales OK",
"obsidianLiveSyncSettingTab.okCorsOriginMatched": "✔ Origen de CORS correcto",
"obsidianLiveSyncSettingTab.okCorsOrigins": "✔ cors.origins está correcto.",
"obsidianLiveSyncSettingTab.okEnableCors": "✔ httpd.enable_cors está correcto.",
"obsidianLiveSyncSettingTab.okCorsOriginMatched": "✔ Origen de CORS es correcto",
"obsidianLiveSyncSettingTab.okCorsOrigins": "✔ cors.origins es correcto.",
"obsidianLiveSyncSettingTab.okEnableCors": "✔ httpd.enable_cors es correcto.",
"obsidianLiveSyncSettingTab.okEnableCorsChttpd": "✔ chttpd.enable_cors es correcto.",
"obsidianLiveSyncSettingTab.okMaxDocumentSize": "✔ couchdb.max_document_size está correcto.",
"obsidianLiveSyncSettingTab.okMaxRequestSize": "✔ chttpd.max_http_request_size está correcto.",
"obsidianLiveSyncSettingTab.okRequireValidUser": "✔ chttpd.require_valid_user está correcto.",
"obsidianLiveSyncSettingTab.okRequireValidUserAuth": "✔ chttpd_auth.require_valid_user está correcto.",
"obsidianLiveSyncSettingTab.okWwwAuth": "✔ httpd.WWW-Authenticate está correcto.",
"obsidianLiveSyncSettingTab.okMaxDocumentSize": "✔ couchdb.max_document_size es correcto.",
"obsidianLiveSyncSettingTab.okMaxRequestSize": "✔ chttpd.max_http_request_size es correcto.",
"obsidianLiveSyncSettingTab.okRequireValidUser": "✔ chttpd.require_valid_user es correcto.",
"obsidianLiveSyncSettingTab.okRequireValidUserAuth": "✔ chttpd_auth.require_valid_user es correcto.",
"obsidianLiveSyncSettingTab.okWwwAuth": "✔ httpd.WWW-Authenticate es correcto.",
"obsidianLiveSyncSettingTab.optionApply": "Aplicar",
"obsidianLiveSyncSettingTab.optionCancel": "Cancelar",
"obsidianLiveSyncSettingTab.optionCouchDB": "CouchDB",
@@ -693,7 +693,7 @@
"obsidianLiveSyncSettingTab.optionOnEvents": "En eventos",
"obsidianLiveSyncSettingTab.optionPeriodicAndEvents": "Periódico y en eventos",
"obsidianLiveSyncSettingTab.optionPeriodicWithBatch": "Periódico con lote",
"obsidianLiveSyncSettingTab.optionRebuildBoth": "Reconstructuir ambos desde este dispositivo",
"obsidianLiveSyncSettingTab.optionRebuildBoth": "Reconstruir ambos desde este dispositivo",
"obsidianLiveSyncSettingTab.optionSaveOnlySettings": "(Peligro) Guardar solo configuración",
"obsidianLiveSyncSettingTab.panelChangeLog": "Registro de cambios",
"obsidianLiveSyncSettingTab.panelGeneralSettings": "Configuraciones generales",
@@ -739,7 +739,7 @@
"On": "Activado",
"On the source device, from the command palette, run the 'Show settings as a QR code' command.": "En el dispositivo de origen, ejecuta desde la paleta de comandos «Show settings as a QR code».",
"On the source device, open Obsidian.": "En el dispositivo de origen, abre Obsidian.",
"On this device, please keep this Vault open.": "En este dispositivo, mantén este Vault abierto.",
"On this device, please keep this Vault open.": "En este dispositivo, mantén esta bóveda abierta.",
"On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.": "En este dispositivo, cambia a la aplicación de cámara o usa un lector de QR para escanear el código mostrado.",
"Open": "Abrir",
"Open connection": "Abrir conexión",
@@ -760,7 +760,7 @@
"P2P.FailedToOpen": "No se pudo abrir la conexión P2P con el servidor de señalización.",
"P2P.NoAutoSyncPeers": "No se han encontrado pares de sincronización automática. Configúralos en el panel de %{long_p2p_sync}.",
"P2P.NoKnownPeers": "No se ha detectado ningún par; esperando a que se conecten otros...",
"P2P.Note.description": " Este replicador permite sincronizar el vault con otros dispositivos\nmediante una conexión punto a punto. Así se puede sincronizar con nuestros otros dispositivos sin usar un servicio en la nube.\nEl replicador se basa en Trystero. Usa además un servidor de señalización para establecer la conexión entre dispositivos. Ese servidor sirve para intercambiar la información de conexión y no conoce (ni debería almacenar) ninguno de nuestros datos.\n\nCualquiera puede alojar el servidor de señalización: es simplemente un relé Nostr. Por comodidad y para poder comprobar el comportamiento del replicador, vrtmrz aloja una instancia. Puedes usar ese servidor experimental o cualquier otro.\n\nPor cierto, aunque el servidor de señalización no almacene nuestros datos, sí puede ver la información de conexión de algunos de nuestros dispositivos. Tenlo en cuenta y ten precaución al usar un servidor de terceros.",
"P2P.Note.description": " Este replicador permite sincronizar la bóveda con otros dispositivos\nmediante una conexión punto a punto. Así se puede sincronizar con nuestros otros dispositivos sin usar un servicio en la nube.\nEl replicador se basa en Trystero. Usa además un servidor de señalización para establecer la conexión entre dispositivos. Ese servidor sirve para intercambiar la información de conexión y no conoce (ni debería almacenar) ninguno de nuestros datos.\n\nCualquiera puede alojar el servidor de señalización: es simplemente un relé Nostr. Por comodidad y para poder comprobar el comportamiento del replicador, vrtmrz aloja una instancia. Puedes usar ese servidor experimental o cualquier otro.\n\nPor cierto, aunque el servidor de señalización no almacene nuestros datos, sí puede ver la información de conexión de algunos de nuestros dispositivos. Tenlo en cuenta y ten precaución al usar un servidor de terceros.",
"P2P.Note.important_note": "Replicador punto a punto.",
"P2P.Note.important_note_sub": "Esta función sigue siendo muy experimental. Asegúrate de tener una copia de seguridad de tus datos antes de usarla. Y nos alegraría mucho que quisieras contribuir a su desarrollo.",
"P2P.Note.Summary": "¿Qué es esta función? (incluye notas importantes; léelas al menos una vez)",
@@ -782,7 +782,7 @@
"password": "contraseña",
"Password": "Contraseña",
"Paste a connection string": "Pegar cadena de conexión",
"Paste the Setup URI generated from one of your active devices.": "Pegue el URI de configuración generado desde uno de sus dispositivos activos。",
"Paste the Setup URI generated from one of your active devices.": "Pega el URI de configuración generado desde uno de tus dispositivos activos。",
"Path": "Ruta",
"Path Obfuscation": "Ofuscación de rutas",
"Patterns to match files for overwriting instead of merging": "Patrones para identificar archivos que se sobrescribirán en lugar de fusionarse",
@@ -808,16 +808,16 @@
"Please enter the CouchDB server information below.": "Introduce a continuación los datos del servidor CouchDB.",
"Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.": "Introduce los datos necesarios para conectarte a tu servicio de almacenamiento de objetos compatible con S3/MinIO/R2.",
"Please enter the Peer-to-Peer Synchronisation information below.": "Introduce a continuación los datos de la sincronización punto a punto.",
"Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.": "Introduce el Setup URI generado durante la instalación del servidor o en otro dispositivo, junto con la frase de contraseña del vault.",
"Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.": "Introduce el Setup URI generado durante la instalación del servidor o en otro dispositivo, junto con la frase de contraseña de la bóveda.",
"Please follow the steps below to import settings from your existing device.": "Sigue los pasos siguientes para importar los ajustes desde tu dispositivo actual.",
"PLEASE NOTE": "TEN EN CUENTA",
"Please select 'Cancel' explicitly to cancel this operation.": "Selecciona «Cancelar» de forma explícita para cancelar esta operación.",
"Please select a method to import the settings from another device.": "Seleccione un método para importar la configuración desde otro dispositivo。",
"Please select a method to import the settings from another device.": "Selecciona un método para importar la configuración desde otro dispositivo。",
"Please select an active P2P remote configuration to change P2P sync targets.": "Selecciona una configuración remota P2P activa para cambiar los destinos de sincronización P2P.",
"Please select an option to proceed": "Seleccione una opción para continuar",
"Please select an option to proceed": "Selecciona una opción para continuar",
"Please select the button below to restart and proceed to the data fetching confirmation.": "Pulsa el botón de abajo para reiniciar y pasar a la confirmación de la obtención de datos.",
"Please select the button below to restart and proceed to the final confirmation.": "Pulsa el botón de abajo para reiniciar y pasar a la confirmación final.",
"Please select the type of server to which you are connecting.": "Seleccione el tipo de servidor al que se está conectando。",
"Please select the type of server to which you are connecting.": "Selecciona el tipo de servidor al que se está conectando。",
"Please select your situation.": "Selecciona tu situación.",
"Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": "Define un nombre para identificar este dispositivo. Debe ser único entre tus dispositivos. Mientras no esté configurado, no podremos habilitar esta función.",
"Please set this device name": "Define el nombre de este dispositivo",
@@ -855,7 +855,7 @@
"RedFlag.FetchRemoteConfig.Message": "¿Quieres obtener y aplicar en este dispositivo los ajustes de preferencias guardados en el remoto?",
"RedFlag.FetchRemoteConfig.Title": "Obtener la configuración remota",
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": "Reduce el espacio de almacenamiento descartando todas las revisiones que no sean la última. Requiere la misma cantidad de espacio libre en el servidor remoto y en el cliente local.",
"Reducing the frequency with which on-disk changes are reflected into the DB": "Reducir frecuencia de actualizaciones de disco a BD",
"Reducing the frequency with which on-disk changes are reflected into the DB": "Reducir frecuencia de actualizaciones de disco a base de datos",
"Refresh": "Actualizar",
"Region": "Región",
"Relay settings": "Ajustes del relé",
@@ -885,7 +885,7 @@
"Replicator.Message.SomeModuleFailed": "La replicación se ha cancelado por el fallo de algún módulo",
"Replicator.Message.VersionUpFlash": "Se ha detectado una actualización. Abre el diálogo de ajustes y consulta el registro de cambios. La replicación se ha cancelado.",
"Requires restart of Obsidian": "Requiere reiniciar Obsidian",
"Requires restart of Obsidian.": "Requiere reiniciar Obsidian",
"Requires restart of Obsidian.": "Requiere reiniciar Obsidian.",
"Rerun Onboarding Wizard": "Volver a ejecutar el asistente de configuración inicial",
"Rerun the onboarding wizard to set up Self-hosted LiveSync again.": "Vuelve a ejecutar el asistente de configuración inicial para configurar Self-hosted LiveSync de nuevo.",
"Rerun Wizard": "Volver a ejecutar el asistente",
@@ -938,19 +938,19 @@
"Scan for hidden files before replication": "Escanear archivos ocultos antes de replicar",
"Scan hidden files periodically": "Escanear archivos ocultos periódicamente",
"Scan QR Code": "Escanear código QR",
"Scan the QR code displayed on an active device using this device's camera.": "Escanee con la cámara de este dispositivo el código QR mostrado en un dispositivo activo。",
"Scan the QR code displayed on an active device using this device's camera.": "Escanea con la cámara de este dispositivo el código QR mostrado en un dispositivo activo。",
"Schedule and Restart": "Programar y reiniciar",
"Scram Switches": "Interruptores de emergencia",
"Scram!": "Medidas de emergencia",
"Seconds, 0 to disable": "Segundos, 0 para desactivar",
"Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": "Segundos. Guardado en BD local se retrasará hasta este valor tras dejar de escribir/guardar",
"Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": "Segundos. Guardado en base de datos local se retrasará hasta este valor tras dejar de escribir o guardar.",
"Secret Access Key": "Clave de acceso secreta",
"Secret Key": "Clave secreta",
"Select active P2P remote": "Seleccionar el remoto P2P activo",
"Select All Shiny": "Seleccionar todo lo nuevo",
"Select Flagged Shiny": "Seleccionar lo nuevo marcado",
"Select P2P remote...": "Seleccionar remoto P2P...",
"Select the database adapter to use.": "Selecciona el adaptador de base de datos que se usará.",
"Select the database adapter to use.": "Selecciona el adaptador de base de datos.",
"Selecting this option will result in the current data on this device being used to initialise the server. Any existing data on the server will be completely overwritten.": "Al elegir esta opción, los datos actuales de este dispositivo se usarán para inicializar el servidor. Cualquier dato existente en el servidor se sobrescribirá por completo.",
"Selecting this option will result in this device joining the existing server. You need to fetching the existing synchronisation data from the server to this device.": "Al elegir esta opción, este dispositivo se unirá al servidor existente. Tendrás que obtener del servidor los datos de sincronización ya existentes.",
"Selective": "Selectivo",
@@ -1027,7 +1027,7 @@
"Setup.RemoteE2EE.AlgorithmWarning": "Cambiar el algoritmo de cifrado impedirá el acceso a cualquier dato cifrado anteriormente con otro algoritmo. Asegúrate de que todos tus dispositivos estén configurados para usar el mismo algoritmo y así mantener el acceso a tus datos.",
"Setup.RemoteE2EE.ButtonCancel": "Cancelar",
"Setup.RemoteE2EE.ButtonProceed": "Continuar",
"Setup.RemoteE2EE.DefaultAlgorithmDesc": "En la mayoría de los casos, debes mantener el algoritmo predeterminado (${algorithm}). Este ajuste solo es necesario si ya tienes un Vault cifrado con un formato diferente.",
"Setup.RemoteE2EE.DefaultAlgorithmDesc": "En la mayoría de los casos, debes mantener el algoritmo predeterminado (${algorithm}). Este ajuste solo es necesario si ya tienes una bóveda cifrada con un formato diferente.",
"Setup.RemoteE2EE.Guidance": "Configura tus ajustes de cifrado de extremo a extremo.",
"Setup.RemoteE2EE.LabelEncrypt": "Cifrado de extremo a extremo",
"Setup.RemoteE2EE.LabelEncryptionAlgorithm": "Algoritmo de cifrado",
@@ -1043,7 +1043,7 @@
"Setup.RemoteE2EE.Title": "Cifrado de extremo a extremo",
"Setup.ScanQRCode.ButtonClose": "Cerrar este diálogo",
"Setup.ScanQRCode.Guidance": "Sigue los pasos de abajo para importar los ajustes desde tu dispositivo actual.",
"Setup.ScanQRCode.Step1": "En este dispositivo, mantén este Vault abierto.",
"Setup.ScanQRCode.Step1": "En este dispositivo, mantén esta bóveda abierta.",
"Setup.ScanQRCode.Step2": "En el dispositivo de origen, abre Obsidian.",
"Setup.ScanQRCode.Step3": "En el dispositivo de origen, ejecuta desde la paleta de comandos la orden \"Mostrar ajustes como código QR\".",
"Setup.ScanQRCode.Step4": "En este dispositivo, cambia a la cámara o usa un escáner QR para escanear el código mostrado.",
@@ -1059,13 +1059,13 @@
"Setup.UseSetupURI.ButtonCancel": "Cancelar",
"Setup.UseSetupURI.ButtonProceed": "Probar ajustes y continuar",
"Setup.UseSetupURI.ErrorFailedToParse": "No se pudo procesar la URI de configuración. Revisa la URI y la frase de contraseña.",
"Setup.UseSetupURI.ErrorPassphraseRequired": "Introduce la frase de contraseña del Vault.",
"Setup.UseSetupURI.GuidanceLine1": "Introduce la URI de configuración que se generó durante la instalación del servidor o en otro dispositivo, junto con la frase de contraseña del Vault.",
"Setup.UseSetupURI.ErrorPassphraseRequired": "Introduce la frase de contraseña de la bóveda.",
"Setup.UseSetupURI.GuidanceLine1": "Introduce la URI de configuración que se generó durante la instalación del servidor o en otro dispositivo, junto con la frase de contraseña de la bóveda.",
"Setup.UseSetupURI.GuidanceLine2": "Ten en cuenta que puedes generar una nueva URI de configuración ejecutando el comando \"Copiar ajustes como nueva URI de configuración\" desde la paleta de comandos.",
"Setup.UseSetupURI.InvalidInfo": "La URI de configuración no es válida. Revísala e inténtalo de nuevo.",
"Setup.UseSetupURI.LabelPassphrase": "Frase de contraseña del Vault",
"Setup.UseSetupURI.LabelPassphrase": "Frase de contraseña de la bóveda",
"Setup.UseSetupURI.LabelSetupURI": "URI de configuración",
"Setup.UseSetupURI.PlaceholderPassphrase": "Introduce la frase de contraseña del Vault",
"Setup.UseSetupURI.PlaceholderPassphrase": "Introduce la frase de contraseña de la bóveda",
"Setup.UseSetupURI.Title": "Introducir URI de configuración",
"Setup.UseSetupURI.ValidInfo": "La URI de configuración es válida y está lista para usarse.",
"Should we keep folders that don't have any files inside?": "¿Mantener carpetas vacías?",
@@ -1097,7 +1097,7 @@
"Stats": "Estadísticas",
"Stop ⚡": "Detener ⚡",
"Stop Broadcasting": "Detener difusión",
"Stop reflecting database changes to storage files.": "Dejar de reflejar cambios de BD en archivos",
"Stop reflecting database changes to storage files.": "Dejar de reflejar cambios de base de datos en archivos",
"Stop watching for file changes.": "Dejar de monitorear cambios en archivos",
"Storage -> Database": "Almacenamiento -> Base de datos",
"Strongly Recommended": "Muy recomendado",
@@ -1126,7 +1126,7 @@
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Solo pruebas - Resolver conflictos sincronizando copias nuevas (puede sobrescribir modificaciones)",
"The connection to the server has been configured successfully. As the next step,": "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
"The delay for consecutive on-demand fetches": "Retraso entre obtenciones consecutivas",
"The files in this Vault are almost identical to the server's.": "Los archivos de este Vault son casi idénticos a los del servidor.",
"The files in this Vault are almost identical to the server's.": "Los archivos de esta bóveda son casi idénticos a los del servidor.",
"The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": "A los siguientes nodos aceptados les falta su información de nodo:\n- ${missingNodes}\n\nEsto indica que llevan tiempo sin conectarse o que se han quedado en una versión antigua.\nSi es posible, conviene actualizar todos los dispositivos. Si tienes dispositivos que ya no usas, puedes borrar todos los nodos aceptados bloqueando el remoto una vez.",
"The Group ID and passphrase are used to identify your group of devices. Make sure to use the same Group ID and passphrase on all devices you want to synchronise.": "El ID de grupo y la frase de contraseña identifican tu grupo de dispositivos. Usa el mismo ID de grupo y la misma frase de contraseña en todos los dispositivos que quieras sincronizar.",
"The Hash algorithm for chunk IDs": "Algoritmo hash para IDs de chunks",
@@ -1137,7 +1137,7 @@
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "Número máximo de chunks que pueden incubarse en el documento. Excedentes se independizan",
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "Tamaño total máximo de chunks incubados. Excedentes se independizan",
"The minimum interval for automatic synchronisation on event.": "Intervalo mínimo para la sincronización automática al producirse un evento.",
"The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication.": "La base de datos remota se ha bloqueado para la sincronización con el fin de evitar la corrupción del vault, porque este dispositivo no está marcado como «resuelto». Haz una copia de seguridad de tu vault, restablece la base de datos local y selecciona «Marcar este dispositivo como resuelto». Este aviso persistirá hasta que la réplica confirme que el dispositivo está resuelto.",
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "La base de datos remota está bloqueada para la sincronización a fin de evitar que la bóveda se corrompa, porque este dispositivo no está marcado como «resuelto». Haz una copia de seguridad de tu bóveda, restablece la base de datos local y selecciona «Marcar este dispositivo como resuelto». Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto.",
"The remote is already set up, and the configuration is compatible (or got compatible by this operation).": "El remoto ya está configurado y la configuración es compatible (o pasa a serlo con esta operación).",
"The Setup-URI does not appear to be valid. Please check that you have copied it correctly.": "El Setup-URI no parece válido. Comprueba que lo hayas copiado correctamente.",
"The Setup-URI is valid and ready to use.": "El Setup-URI es válido y está listo para usarse.",
@@ -1145,7 +1145,7 @@
"the synchronisation data on the server will be built based on the current data on this device.": "los datos de sincronización del servidor se construirán a partir de los datos actuales de este dispositivo.",
"Themes": "Temas",
"There is a way to resolve this on other devices.": "Hay una forma de resolver esto en los demás dispositivos.",
"There may be differences between the files in this Vault and the server.": "Puede haber diferencias entre los archivos de este Vault y los del servidor.",
"There may be differences between the files in this Vault and the server.": "Puede haber diferencias entre los archivos de esta bóveda y los del servidor.",
"Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted.": "Por tanto, extrema la precaución al configurar manualmente los datos del servidor. Si introduces una frase de contraseña incorrecta, los datos del servidor se corromperán.",
"This can isolate your connections between devices. Use the same Room ID for the same devices.": "Esto permite aislar tus conexiones entre dispositivos. Usa el mismo ID de sala para los mismos dispositivos.",
"This device": "Este dispositivo",
@@ -1153,18 +1153,18 @@
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "Esta función permite la sincronización directa entre dispositivos. No requiere servidor, pero ambos dispositivos deben estar en línea al mismo tiempo para que la sincronización se produzca, y algunas funciones pueden ser limitadas. La conexión a Internet solo se necesita para la señalización (detección de pares), no para la transferencia de datos。",
"This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "Esta primera configuración consta de varios pasos breves, ya que confirma el cifrado, el método de conexión y qué dispositivo aporta los datos iniciales. Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "Esta es una opción avanzada para usuarios que no disponen de un URI o que desean configurar parámetros detallados。",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "Esta es una operación extremadamente potente. Te recomendamos encarecidamente copiar la carpeta de tu Vault a un lugar seguro.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "Este es el método de sincronización más adecuado para el diseño. Todas las funciones están disponibles. Debe tener configurada una instancia de CouchDB。",
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "Esta es una operación extremadamente potente. Te recomendamos encarecidamente copiar la carpeta de tu bóveda a un lugar seguro.",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "Este es el método de sincronización más adecuado para el diseño del complemento. Todas las funciones están disponibles. Debe tener configurada una instancia de CouchDB。",
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.": "Esta comprobación opcional usa la API interna de solicitudes de Obsidian y envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un servidor de confianza; puede requerir acceso de administrador.",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "Esta frase no se copia a otros dispositivos. Usará `Default` hasta que lo configures de nuevo.",
"This password is used to encrypt the connection. Use something long enough.": "Esta contraseña se usa para cifrar la conexión. Usa algo suficientemente largo.",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "Este procedimiento eliminará primero todos los datos de sincronización existentes en el servidor. A continuación, los datos del servidor se reconstruirán por completo usando el estado actual del Vault de este dispositivo (incluida su base de datos local) como",
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "Este procedimiento eliminará primero todos los datos de sincronización existentes en el servidor. A continuación, los datos del servidor se reconstruirán por completo usando el estado actual de la bóveda de este dispositivo (incluida su base de datos local) como",
"This setting must be the same even when connecting to multiple synchronisation destinations.": "Este ajuste debe ser el mismo incluso si te conectas a varios destinos de sincronización.",
"This Vault is empty, or contains only new files that are not on the server.": "Este Vault está vacío o solo contiene archivos nuevos que no están en el servidor.",
"This Vault is empty, or contains only new files that are not on the server.": "Esta bóveda está vacía o solo contiene archivos nuevos que no están en el servidor.",
"This will rebuild the local database on this device using the most recent data from the server. This action is designed to resolve synchronisation inconsistencies and restore correct functionality.": "Esto reconstruirá la base de datos local de este dispositivo con los datos más recientes del servidor. La acción está pensada para resolver inconsistencias de sincronización y restaurar el funcionamiento correcto.",
"This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": "Esto recreará los fragmentos de todos los archivos. Si faltaban fragmentos, esto puede corregir los errores.",
"To minimise the creation of new conflicts": "Para minimizar la creación de nuevos conflictos",
"To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication": "Para evitar una corrupción no deseada del vault, la base de datos remota se ha bloqueado para la sincronización. (Este dispositivo está marcado como «resuelto») Cuando todos tus dispositivos estén marcados como «resueltos», desbloquea la base de datos. Este aviso seguirá apareciendo hasta que la réplica confirme que el dispositivo está resuelto",
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "Para evitar que la bóveda se corrompa, la base de datos remota se ha bloqueado para la sincronización. (Este dispositivo está marcado como «resuelto».) Cuando todos tus dispositivos estén marcados como «resueltos», desbloquea la base de datos. Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto.",
"Transfer Tweak": "Ajustes de transferencia",
"TURN Credential": "Credencial de TURN",
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank.": "Los ajustes del servidor TURN solo son necesarios si estás detrás de un NAT estricto o de un cortafuegos que impide las conexiones P2P directas. En la mayoría de los casos puedes dejar estos campos vacíos.",
@@ -1292,10 +1292,6 @@
"Ui.Settings.Maintenance.Scram": "¡Parada de emergencia!",
"Ui.Settings.Maintenance.SendChunks": "Enviar los chunks",
"Ui.Settings.Maintenance.Syncing": "Sincronización",
"Ui.Settings.Maintenance.WarningLockedReadyAction": "Estoy listo, desbloquear la base de datos",
"Ui.Settings.Maintenance.WarningLockedReadyText": "Para evitar que el vault se corrompa, la base de datos remota se ha bloqueado para la sincronización. (Este dispositivo está marcado como «resuelto».) Cuando todos tus dispositivos estén marcados como «resueltos», desbloquea la base de datos. Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto.",
"Ui.Settings.Maintenance.WarningLockedResolveAction": "He hecho una copia de seguridad, marcar este dispositivo como resuelto",
"Ui.Settings.Maintenance.WarningLockedResolveText": "La base de datos remota está bloqueada para la sincronización a fin de evitar que el vault se corrompa, porque este dispositivo no está marcado como «resuelto». Haz una copia de seguridad de tu vault, restablece la base de datos local y selecciona «Marcar este dispositivo como resuelto». Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto.",
"Ui.Settings.Maintenance.WriteRedFlagAndRestart": "Marcar y reiniciar",
"Ui.Settings.Patches.CompatibilityConflict": "Compatibilidad (comportamiento ante conflictos)",
"Ui.Settings.Patches.CompatibilityDatabase": "Compatibilidad (estructura de la base de datos)",
@@ -1324,7 +1320,7 @@
"Ui.Settings.Patches.RemediationNoLimit": "Sin límite configurado",
"Ui.Settings.Patches.RemediationRestarting": "Se ha cambiado el ajuste de remediación. Reiniciando Obsidian...",
"Ui.Settings.Patches.RemediationRestartLater": "Más tarde",
"Ui.Settings.Patches.RemediationRestartMessage": "Se recomienda encarecidamente reiniciar Obsidian. Hasta que lo hagas, puede que algunos cambios no surtan efecto y que la interfaz se muestre de forma inconsistente. ¿Seguro que quieres reiniciar ahora?",
"Ui.Settings.Patches.RemediationRestartMessage": "Se recomienda encarecidamente reiniciar Obsidian. Hasta que lo hagas puede que algunos cambios no surtan efecto y que la interfaz se muestre de forma inconsistente. ¿Seguro que quieres reiniciar ahora?",
"Ui.Settings.Patches.RemediationRestartNow": "Reiniciar ahora",
"Ui.Settings.Patches.RemediationSuffixChanged": "El sufijo ha cambiado. Reabriendo la base de datos...",
"Ui.Settings.Patches.RemediationWithValue": "Límite: ${date} (${timestamp})",
@@ -1351,7 +1347,7 @@
"Ui.Settings.Remote.DisplayName": "Nombre visible",
"Ui.Settings.Remote.DuplicateRemote": "Duplicar el remoto",
"Ui.Settings.Remote.DuplicateRemoteSuffix": "${name} (copia)",
"Ui.Settings.Remote.E2EEConfiguration": "Configuración del E2EE",
"Ui.Settings.Remote.E2EEConfiguration": "Configuración de E2EE",
"Ui.Settings.Remote.Export": "Exportar",
"Ui.Settings.Remote.FetchRemoteSettings": "Obtener los ajustes remotos",
"Ui.Settings.Remote.ImportConnection": "Importar conexión",
@@ -1412,7 +1408,7 @@
"Ui.SetupWizard.OutroAskUserMode.Question": "Selecciona tu situación.",
"Ui.SetupWizard.OutroAskUserMode.Title": "Casi terminado: se requiere una decisión",
"Ui.SetupWizard.OutroNewP2PUser.GuidanceNotice": "En P2P no hay una copia en un servidor central que sobrescribir. Este paso prepara solo este dispositivo; mantenlo conectado cuando otro dispositivo obtenga sus datos iniciales.",
"Ui.SetupWizard.OutroNewP2PUser.GuidancePrimary": "La conexión punto a punto se ha configurado correctamente. A continuación, la base de datos local de LiveSync se construirá a partir de los archivos actuales de este Vault.",
"Ui.SetupWizard.OutroNewP2PUser.GuidancePrimary": "La conexión punto a punto se ha configurado correctamente. A continuación, la base de datos local de LiveSync se construirá a partir de los archivos actuales de esta bóveda.",
"Ui.SetupWizard.OutroNewP2PUser.Important": "TEN EN CUENTA",
"Ui.SetupWizard.OutroNewP2PUser.Proceed": "Reiniciar y preparar este dispositivo",
"Ui.SetupWizard.OutroNewP2PUser.Question": "Pulsa el botón de abajo para reiniciar y pasar a la confirmación de la inicialización local.",
@@ -1424,10 +1420,10 @@
"Ui.SetupWizard.OutroNewUser.Question": "Pulsa el botón de abajo para reiniciar y pasar a la confirmación final.",
"Ui.SetupWizard.OutroNewUser.Title": "Configuración completada: preparando la inicialización del servidor",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalReset": "Entiendo que esto restablece únicamente la base de datos de sincronización local de este dispositivo.",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalResetNote": "Se usarán los archivos que hay ahora en este Vault para reconstruirla.",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalResetNote": "Se usarán los archivos que hay ahora en esta bóveda para reconstruirla.",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmTitle": "⚠️ Confirma lo siguiente",
"Ui.SetupWizard.RebuildEverythingP2P.Guidance": "Este procedimiento descartará la base de datos local de LiveSync de este dispositivo y la reconstruirá a partir de los archivos actuales de este Vault. No elimina ni sobrescribe datos de otro dispositivo.",
"Ui.SetupWizard.RebuildEverythingP2P.Note": "Mantén este dispositivo conectado después de la inicialización para que otro dispositivo pueda obtener el Vault desde él.",
"Ui.SetupWizard.RebuildEverythingP2P.Guidance": "Este procedimiento descartará la base de datos local de LiveSync de este dispositivo y la reconstruirá a partir de los archivos actuales de esta bóveda. No elimina ni sobrescribe datos de otro dispositivo.",
"Ui.SetupWizard.RebuildEverythingP2P.Note": "Mantén este dispositivo conectado después de la inicialización para que otro dispositivo pueda obtener la bóveda desde él.",
"Ui.SetupWizard.RebuildEverythingP2P.Proceed": "Lo entiendo, preparar este dispositivo",
"Ui.SetupWizard.RebuildEverythingP2P.Title": "Confirmación final: preparar este dispositivo para P2P",
"Ui.SetupWizard.SelectExisting.Guidance": "Estás añadiendo este dispositivo a una configuración de sincronización existente.",
@@ -1453,7 +1449,7 @@
"Ui.SetupWizard.SelectNew.Title": "Método de conexión",
"Ui.SetupWizard.SetupRemote.BucketOption": "Almacenamiento de objetos compatible con S3",
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": "Sincronización mediante archivos de diario. Necesitas tener ya un servicio de almacenamiento de objetos compatible con S3, como Amazon S3, MinIO o Cloudflare R2.",
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": "Es el método de sincronización más adecuado para el diseño actual y ofrece todas las funciones. Necesitas tener ya una instancia de CouchDB en marcha.",
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": "Es el método de sincronización más adecuado para el diseño actual y ofrece todas las funciones. Necesitas tener una instancia de CouchDB en marcha.",
"Ui.SetupWizard.SetupRemote.Guidance": "Selecciona el tipo de remoto para esta configuración de sincronización.",
"Ui.SetupWizard.SetupRemote.P2POption": "Punto a punto (P2P)",
"Ui.SetupWizard.SetupRemote.P2POptionDesc": "Permite la sincronización directa entre dispositivos. No hace falta servidor, pero ambos dispositivos deben estar conectados a la vez y algunas funciones pueden estar limitadas. Solo se necesita internet para la señalización, no para transferir los datos.",
@@ -1466,7 +1462,7 @@
"Updating list...": "Actualizando lista...",
"URL": "URL",
"Use a custom passphrase": "Usar una frase de contraseña personalizada",
"Use a Setup URI (Recommended)": "Usar un URI de configuración (recomendado)",
"Use a Setup URI (Recommended)": "Usar URI de configuración (recomendado)",
"Use Custom HTTP Handler": "Usar manejador HTTP personalizado",
"Use Diagnostic RTCPeerConnection for statistics": "Usar el RTCPeerConnection de diagnóstico para las estadísticas",
"Use dynamic iteration count": "Usar conteo de iteraciones dinámico",
@@ -1493,8 +1489,8 @@
"We can use only Secure (HTTPS) connections on Obsidian Mobile.": "En Obsidian Mobile solo se pueden usar conexiones seguras (HTTPS).",
"We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": "No podemos cambiar el nombre del dispositivo mientras esta función esté habilitada. Deshabilita la función para cambiarlo.",
"We have to configure the device name": "Hay que configurar el nombre del dispositivo",
"We recommend that you copy your Vault folder to a safe location. This will provide a safeguard in case a large number of conflicts arise, or if you accidentally synchronise with an incorrect destination.": "Te recomendamos copiar la carpeta de tu Vault a un lugar seguro. Así tendrás una salvaguarda en caso de que surjan muchos conflictos o de que sincronices por error con un destino incorrecto.",
"We will now guide you through a few questions to simplify the synchronisation setup.": "Ahora le guiaremos con unas pocas preguntas para simplificar la configuración de la sincronización。",
"We recommend that you copy your Vault folder to a safe location. This will provide a safeguard in case a large number of conflicts arise, or if you accidentally synchronise with an incorrect destination.": "Te recomendamos copiar la carpeta de tu bóveda a un lugar seguro. Así tendrás una copia de seguridad en caso de que surjan muchos conflictos o de que sincronices por error con un destino incorrecto.",
"We will now guide you through a few questions to simplify the synchronisation setup.": "Ahora te guiaremos con unas pocas preguntas para simplificar la configuración de la sincronización。",
"We will now proceed with the server configuration.": "Ahora continuaremos con la configuración del servidor。",
"Welcome to Self-hosted LiveSync": "Bienvenido a Self-hosted LiveSync",
"When you save a file in the editor, start a sync automatically": "Iniciar sincronización automática al guardar en editor",
@@ -1504,9 +1500,9 @@
"xxhash64 (Fastest)": "xxhash64 (el más rápido)",
"Yes, I want to add this device to my existing synchronisation": "Sí, quiero añadir este dispositivo a mi sincronización existente",
"Yes, I want to set up a new synchronisation": "Sí, quiero configurar una nueva sincronización",
"You are adding this device to an existing synchronisation setup.": "Está añadiendo este dispositivo a una configuración de sincronización existente。",
"You are adding this device to an existing synchronisation setup.": "Estás añadiendo este dispositivo a una configuración de sincronización existente。",
"You can configure in the Obsidian Plugin Settings.": "Puedes configurarlo en los ajustes del complemento de Obsidian.",
"You should create a new synchronisation destination and rebuild your data there.": "Deberías crear un nuevo destino de sincronización y reconstruir allí tus datos.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "Solo deberías realizar esta operación en circunstancias excepcionales: cuando los datos del servidor estén completamente corruptos, cuando ya no necesites los cambios de los demás dispositivos o cuando el tamaño de la base de datos sea inusualmente grande respecto al del Vault.",
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "Solo deberías realizar esta operación en circunstancias excepcionales: cuando los datos del servidor estén completamente corruptos, cuando ya no necesites los cambios de los demás dispositivos o cuando el tamaño de la base de datos sea inusualmente grande respecto a la bóveda.",
"you wanted(Thank you)!": "tu solicitud (¡gracias!)"
}
+4 -4
View File
@@ -309,11 +309,13 @@
"How would you like to configure the connection to your server?": "서버 연결을 어떻게 구성하시겠습니까?",
"However, This should not be enabled if you want to increase your secrecy more.": "다만 기밀성을 더 높이고 싶다면 이 옵션은 활성화하지 않는 것이 좋습니다.",
"I am adding a device to an existing synchronisation setup": "기존 동기화 구성에 기기를 추가합니다",
"I am ready, unlock the database": "준비되었습니다. 데이터베이스 잠금 해제",
"I am setting this up for the first time": "처음으로 설정합니다",
"I am setting up a new server for the first time / I want to reset my existing server.": "새 서버를 처음 설정하거나, 기존 서버를 초기화하려고 합니다.",
"I am unable to create a backup of my Vault.": "보관함을 백업할 수 없습니다.",
"I am unable to create a backup of my Vaults.": "보관함들을 백업할 수 없습니다.",
"I have created a backup of my Vault.": "보관함을 백업했습니다.",
"I have made a backup, mark this device as resolved": "백업했습니다. 이 기기를 해결됨으로 표시",
"I know my server details, let me enter them": "서버 정보를 알고 있으니 직접 입력하겠습니다",
"I understand that all changes made on other smartphones or computers possibly could be lost.": "다른 스마트폰이나 컴퓨터에서 변경한 내용이 모두 사라질 수 있다는 점을 이해합니다.",
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.": "다른 기기는 더 이상 동기화할 수 없게 되며, 동기화 정보를 초기화해야 한다는 점을 이해합니다.",
@@ -1068,6 +1070,7 @@
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "문서 안에 임시 보관할 수 있는 청크의 최대 개수입니다. 이 개수를 넘긴 청크는 즉시 독립된 청크로 분리됩니다.",
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "문서 안에 임시 보관할 수 있는 청크의 최대 총 크기입니다. 이 크기를 넘긴 청크는 즉시 독립된 청크로 분리됩니다.",
"The minimum interval for automatic synchronisation on event.": "이벤트 발생 시 자동 동기화의 최소 간격입니다.",
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "이 기기가 '해결됨'으로 표시되어 있지 않아, 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. 보관함을 백업하고 로컬 데이터베이스를 재설정한 뒤 '이 기기를 해결됨으로 표시'를 선택해 주세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다.",
"The remote is already set up, and the configuration is compatible (or got compatible by this operation).": "원격은 이미 설정되어 있으며, 구성도 호환됩니다(또는 이번 작업으로 호환되었습니다).",
"The Setup-URI does not appear to be valid. Please check that you have copied it correctly.": "Setup-URI가 유효하지 않은 것으로 보입니다. 올바르게 복사했는지 확인해 주세요.",
"The Setup-URI is valid and ready to use.": "Setup-URI가 유효하며 사용할 준비가 되었습니다.",
@@ -1092,6 +1095,7 @@
"This will rebuild the local database on this device using the most recent data from the server. This action is designed to resolve synchronisation inconsistencies and restore correct functionality.": "서버의 최신 데이터를 사용해 이 기기의 로컬 데이터베이스를 재구축합니다. 이 작업은 동기화 불일치를 해결하고 정상적인 동작을 복구하기 위한 것입니다.",
"This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": "모든 파일의 청크를 다시 생성합니다. 누락된 청크가 있었다면 이 작업으로 오류가 해결될 수 있습니다.",
"To minimise the creation of new conflicts": "새로운 충돌 발생을 최소화하기 위해",
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "의도치 않은 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. (이 기기는 '해결됨'으로 표시되어 있습니다.) 모든 기기가 '해결됨'으로 표시되면 데이터베이스 잠금을 해제하세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다.",
"Transfer Tweak": "전송 조정",
"TURN Credential": "TURN 자격 증명",
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank.": "TURN 서버 설정은 직접적인 P2P 연결을 막는 엄격한 NAT나 방화벽 뒤에 있는 경우에만 필요합니다. 대부분의 경우 이 항목들은 비워 두어도 됩니다.",
@@ -1219,10 +1223,6 @@
"Ui.Settings.Maintenance.Scram": "긴급 정지",
"Ui.Settings.Maintenance.SendChunks": "청크 보내기",
"Ui.Settings.Maintenance.Syncing": "동기화",
"Ui.Settings.Maintenance.WarningLockedReadyAction": "준비되었습니다. 데이터베이스 잠금 해제",
"Ui.Settings.Maintenance.WarningLockedReadyText": "의도치 않은 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. (이 기기는 '해결됨'으로 표시되어 있습니다.) 모든 기기가 '해결됨'으로 표시되면 데이터베이스 잠금을 해제하세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다.",
"Ui.Settings.Maintenance.WarningLockedResolveAction": "백업했습니다. 이 기기를 해결됨으로 표시",
"Ui.Settings.Maintenance.WarningLockedResolveText": "이 기기가 '해결됨'으로 표시되어 있지 않아, 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. 보관함을 백업하고 로컬 데이터베이스를 재설정한 뒤 '이 기기를 해결됨으로 표시'를 선택해 주세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다.",
"Ui.Settings.Maintenance.WriteRedFlagAndRestart": "표시 후 재시작",
"Ui.Settings.Patches.CompatibilityConflict": "호환성 (충돌 동작)",
"Ui.Settings.Patches.CompatibilityDatabase": "호환성 (데이터베이스 구조)",
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -309,11 +309,13 @@
"How would you like to configure the connection to your server?": "你希望如何設定與伺服器的連線?",
"However, This should not be enabled if you want to increase your secrecy more.": "不過,如果你想加強保密性,不建議啟用此選項。",
"I am adding a device to an existing synchronisation setup": "我要將裝置加入既有同步設定",
"I am ready, unlock the database": "我已準備好,解鎖資料庫",
"I am setting this up for the first time": "我是第一次進行設定",
"I am setting up a new server for the first time / I want to reset my existing server.": "我是第一次設定新的伺服器/我想要重設現有的伺服器。",
"I am unable to create a backup of my Vault.": "我無法備份我的 Vault。",
"I am unable to create a backup of my Vaults.": "我無法備份我的 Vault。",
"I have created a backup of my Vault.": "我已經備份了我的 Vault。",
"I have made a backup, mark this device as resolved": "我已完成備份,將此裝置標記為已處理",
"I know my server details, let me enter them": "我知道伺服器資訊,讓我手動輸入",
"I understand that all changes made on other smartphones or computers possibly could be lost.": "我了解其他手機或電腦上的所有變更可能會遺失。",
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information.": "我了解其他裝置將無法再進行同步,且需要重設其同步資訊。",
@@ -1065,6 +1067,7 @@
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "文件中可暫存的 chunks 最大數量。超過此數量的 chunks 會立即成為獨立 chunks。",
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "文件中可暫存的 chunks 總大小上限。超過此大小的 chunks 會立即成為獨立 chunks。",
"The minimum interval for automatic synchronisation on event.": "事件觸發自動同步的最小間隔。",
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "由於此裝置尚未標記為「已處理」,為避免 Vault 損毀,遠端資料庫已鎖定同步。請先備份你的 Vault、重設本機資料庫,然後選擇「將此裝置標記為已處理」。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。",
"The remote is already set up, and the configuration is compatible (or got compatible by this operation).": "遠端已經設定完成,且設定相容(或透過此操作變得相容)。",
"The Setup-URI does not appear to be valid. Please check that you have copied it correctly.": "這個 Setup URI 似乎無效,請檢查是否複製正確。",
"The Setup-URI is valid and ready to use.": "Setup URI 有效,可以使用。",
@@ -1089,6 +1092,7 @@
"This will rebuild the local database on this device using the most recent data from the server. This action is designed to resolve synchronisation inconsistencies and restore correct functionality.": "這會使用伺服器上最新的資料,重建此裝置上的本機資料庫。此操作旨在解決同步不一致的問題,並恢復正常功能。",
"This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": "這會為所有檔案重新建立 chunks。若先前有遺失的 chunks,這可能修復相關錯誤。",
"To minimise the creation of new conflicts": "為了盡量減少新增衝突",
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "為避免 Vault 發生非預期的損毀,遠端資料庫已鎖定同步。(此裝置已標記為「已處理」。)當你所有的裝置都標記為「已處理」後,即可解鎖資料庫。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。",
"Transfer Tweak": "傳輸調校",
"TURN Credential": "TURN 憑證",
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank.": "只有在你身處嚴格的 NAT 或防火牆之後、無法建立直接 P2P 連線時,才需要設定 TURN 伺服器。多數情況下這些欄位可以留空。",
@@ -1216,10 +1220,6 @@
"Ui.Settings.Maintenance.Scram": "緊急處置",
"Ui.Settings.Maintenance.SendChunks": "傳送 chunks",
"Ui.Settings.Maintenance.Syncing": "同步中",
"Ui.Settings.Maintenance.WarningLockedReadyAction": "我已準備好,解鎖資料庫",
"Ui.Settings.Maintenance.WarningLockedReadyText": "為避免 Vault 發生非預期的損毀,遠端資料庫已鎖定同步。(此裝置已標記為「已處理」。)當你所有的裝置都標記為「已處理」後,即可解鎖資料庫。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。",
"Ui.Settings.Maintenance.WarningLockedResolveAction": "我已完成備份,將此裝置標記為已處理",
"Ui.Settings.Maintenance.WarningLockedResolveText": "由於此裝置尚未標記為「已處理」,為避免 Vault 損毀,遠端資料庫已鎖定同步。請先備份你的 Vault、重設本機資料庫,然後選擇「將此裝置標記為已處理」。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。",
"Ui.Settings.Maintenance.WriteRedFlagAndRestart": "標記後重新啟動",
"Ui.Settings.Patches.CompatibilityConflict": "相容性(衝突行為)",
"Ui.Settings.Patches.CompatibilityDatabase": "相容性(資料庫結構)",
+4 -4
View File
@@ -172,7 +172,9 @@
"How to display network errors when the sync server is unreachable.": "当同步服务器不可达时,如何显示网络错误。",
"How would you like to configure the connection to your server?": "你希望如何配置与服务器的连接?",
"I am adding a device to an existing synchronisation setup": "我要将设备加入现有同步配置",
"I am ready, unlock the database": "我已准备好,立即解锁数据库",
"I am setting this up for the first time": "我是第一次进行设置",
"I have made a backup, mark this device as resolved": "我已完成备份,将此设备标记为“已确认”",
"I know my server details, let me enter them": "我知道服务器详情,让我手动输入",
"If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).": "如果禁用(切换),chunks 将在 UI 线程上分割(以前的行为)",
"If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.": "如果启用,将使用基于文件的、高效的自定义同步。启用此功能需要进行一次小的迁移。所有设备都应更新到 v0.23.18。一旦启用此功能,我们将失去与旧版本的兼容性",
@@ -769,11 +771,13 @@
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "文档中可以孵化的数据块的最大数量。超过此数量的数据块将立即成为独立数据块 ",
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "文档中可以孵化的数据块的最大总大小。超过此大小的数据块将立即成为独立数据块 ",
"The minimum interval for automatic synchronisation on event.": "基于事件自动同步的最小间隔。",
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "为防止数据仓库损坏,由于此设备尚未标记为“已确认”,远程数据库已被锁定,暂停同步。请先备份你的仓库、重置本地数据库,然后选择“将此设备标记为已确认”。在复制过程确认此设备已完成确认之前,此警告会持续显示。",
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "此功能可在设备之间直接同步,无需服务器;但同步时两台设备必须同时在线,且部分功能可能受限。互联网连接仅用于信令(发现对端),不用于数据传输。",
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "这是面向没有 URI 或希望手动配置详细参数的高级选项。",
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "这是最符合当前设计的同步方式,所有功能均可用。你需要事先部署好 CouchDB 实例。",
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "此密码不会复制到另一台设备。在您再次配置之前,它将设置为 `Default` ",
"This will recreate chunks for all files. If there were missing chunks, this may fix the errors.": "这会为所有文件重新生成 chunks。如果之前存在缺失的 chunks,这可能修复相关错误。",
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "为防止意外的数据仓库损坏,远程数据库已被锁定,暂停同步。(此设备已被标记为“已确认”)当你的所有设备都标记为“已确认”后,再解锁数据库。在复制过程确认此设备已完成确认之前,此警告会持续显示。",
"TweakMismatchResolve.Action.Dismiss": "Dismiss",
"TweakMismatchResolve.Action.UseConfigured": "Use configured settings",
"TweakMismatchResolve.Action.UseMine": "Update remote database settings",
@@ -890,10 +894,6 @@
"Ui.Settings.Maintenance.Scram": "紧急处理",
"Ui.Settings.Maintenance.SendChunks": "发送数据块",
"Ui.Settings.Maintenance.Syncing": "同步",
"Ui.Settings.Maintenance.WarningLockedReadyAction": "我已准备好,立即解锁数据库",
"Ui.Settings.Maintenance.WarningLockedReadyText": "为防止意外的数据仓库损坏,远程数据库已被锁定,暂停同步。(此设备已被标记为“已确认”)当你的所有设备都标记为“已确认”后,再解锁数据库。在复制过程确认此设备已完成确认之前,此警告会持续显示。",
"Ui.Settings.Maintenance.WarningLockedResolveAction": "我已完成备份,将此设备标记为“已确认”",
"Ui.Settings.Maintenance.WarningLockedResolveText": "为防止数据仓库损坏,由于此设备尚未标记为“已确认”,远程数据库已被锁定,暂停同步。请先备份你的仓库、重置本地数据库,然后选择“将此设备标记为已确认”。在复制过程确认此设备已完成确认之前,此警告会持续显示。",
"Ui.Settings.Maintenance.WriteRedFlagAndRestart": "标记并重启",
"Ui.Settings.Patches.CompatibilityConflict": "兼容性(冲突行为)",
"Ui.Settings.Patches.CompatibilityDatabase": "兼容性(数据库结构)",
+22 -26
View File
@@ -31,28 +31,6 @@
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.:
(RegExp) If this is set, any changes to local and remote files that match this
will be skipped.
"Cancel all and reboot": "Cancel all and reboot"
"Compare time and take newer": "Compare time and take newer"
"Conflict & Deletion Options": "Conflict & Deletion Options"
"Data retrieval scheduled": "Data retrieval scheduled"
"Delete local files if deleted on remote": "Delete local files if deleted on remote"
"Delete local files if not on remote": "Delete local files if not on remote"
"Finalise the process and resume normal operation": "Finalise the process and resume normal operation"
"How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation."
"How to handle extra existing local files?": "How to handle extra existing local files?"
"I'm ready, unlock the database": "I'm ready, unlock the database"
"I've made a backup, mark this device 'resolved'": "I've made a backup, mark this device 'resolved'"
"Keep local files even if deleted on remote": "Keep local files even if deleted on remote"
"Keep local files even if not on remote": "Keep local files even if not on remote"
"Overwrite all with remote files": "Overwrite all with remote files"
"Reboot to re-run the process": "Reboot to re-run the process"
"Since you have chosen to overwrite all local files with remote data, **how would you like to handle local files that are not present in the remote database?**\n\n- **${DELETE_ALL}**: Local-only files and remote-deleted files will be removed.\n This option will make your local vault exactly the same as the remote database, but please make sure you have a backup if there is any important data in your vault.\n- **${DELETE_NONE}**: All existing local files will be preserved.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "Since you have chosen to overwrite all local files with remote data, **how would you like to handle local files that are not present in the remote database?**\n\n- **${DELETE_ALL}**: Local-only files and remote-deleted files will be removed.\n This option will make your local vault exactly the same as the remote database, but please make sure you have a backup if there is any important data in your vault.\n- **${DELETE_NONE}**: All existing local files will be preserved.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation."
"Some files failed to synchronise. What would you like to do?": "Some files failed to synchronise. What would you like to do?"
"Synchronisation Issues Detected": "Synchronisation Issues Detected"
"The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication.": "The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication."
"To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication": "To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication"
"Use the detailed flow": "Use the detailed flow"
"We are about to retrieve the remote data.\n\nFirstly, how shall we handle the data retrieved from this remote source?\n\n- **${NEWER_WINS}**: Compares the modified time of files and takes the newer one.\n If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.\n- **${REMOTE_WINS}**: Remote data is the source of truth.\n If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.\n It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.\n- **${DETAILED}**: Opens the detailed setup wizard.\n If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow.": "We are about to retrieve the remote data.\n\nFirstly, how shall we handle the data retrieved from this remote source?\n\n- **${NEWER_WINS}**: Compares the modified time of files and takes the newer one.\n If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.\n- **${REMOTE_WINS}**: Remote data is the source of truth.\n If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.\n It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.\n- **${DETAILED}**: Opens the detailed setup wizard.\n If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow."
"↑: Overwrite Remote": "↑: Overwrite Remote"
"↓: Overwrite Local": "↓: Overwrite Local"
"⇅: Use newer": "⇅: Use newer"
@@ -2257,10 +2235,6 @@ Ui:
Scram: Scram!
SendChunks: Send chunks
Syncing: Syncing
WarningLockedReadyAction: I am ready, unlock the database
WarningLockedReadyText: To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.
WarningLockedResolveAction: I have made a backup, mark this device as resolved
WarningLockedResolveText: The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.
WriteRedFlagAndRestart: Flag and restart
Patches:
CompatibilityConflict: Compatibility (Conflict Behaviour)
@@ -2478,3 +2452,25 @@ You should perform this operation only in exceptional circumstances, such as whe
when the server data is completely corrupted, when changes on all other
devices are no longer needed, or when the database size has become unusually
large in comparison to the Vault size.
"I am ready, unlock the database": "I am ready, unlock the database"
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved."
"I have made a backup, mark this device as resolved": "I have made a backup, mark this device as resolved"
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved."
"Cancel all and reboot": "Cancel all and reboot"
"Compare time and take newer": "Compare time and take newer"
"Conflict & Deletion Options": "Conflict & Deletion Options"
"Data retrieval scheduled": "Data retrieval scheduled"
"Delete local files if deleted on remote": "Delete local files if deleted on remote"
"Delete local files if not on remote": "Delete local files if not on remote"
"Finalise the process and resume normal operation": "Finalise the process and resume normal operation"
"How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation."
"How to handle extra existing local files?": "How to handle extra existing local files?"
"Keep local files even if deleted on remote": "Keep local files even if deleted on remote"
"Keep local files even if not on remote": "Keep local files even if not on remote"
"Overwrite all with remote files": "Overwrite all with remote files"
"Reboot to re-run the process": "Reboot to re-run the process"
"Since you have chosen to overwrite all local files with remote data, **how would you like to handle local files that are not present in the remote database?**\n\n- **${DELETE_ALL}**: Local-only files and remote-deleted files will be removed.\n This option will make your local vault exactly the same as the remote database, but please make sure you have a backup if there is any important data in your vault.\n- **${DELETE_NONE}**: All existing local files will be preserved.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "Since you have chosen to overwrite all local files with remote data, **how would you like to handle local files that are not present in the remote database?**\n\n- **${DELETE_ALL}**: Local-only files and remote-deleted files will be removed.\n This option will make your local vault exactly the same as the remote database, but please make sure you have a backup if there is any important data in your vault.\n- **${DELETE_NONE}**: All existing local files will be preserved.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation."
"Some files failed to synchronise. What would you like to do?": "Some files failed to synchronise. What would you like to do?"
"Synchronisation Issues Detected": "Synchronisation Issues Detected"
"Use the detailed flow": "Use the detailed flow"
"We are about to retrieve the remote data.\n\nFirstly, how shall we handle the data retrieved from this remote source?\n\n- **${NEWER_WINS}**: Compares the modified time of files and takes the newer one.\n If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.\n- **${REMOTE_WINS}**: Remote data is the source of truth.\n If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.\n It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.\n- **${DETAILED}**: Opens the detailed setup wizard.\n If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow.": "We are about to retrieve the remote data.\n\nFirstly, how shall we handle the data retrieved from this remote source?\n\n- **${NEWER_WINS}**: Compares the modified time of files and takes the newer one.\n If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.\n- **${REMOTE_WINS}**: Remote data is the source of truth.\n If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.\n It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.\n- **${DETAILED}**: Opens the detailed setup wizard.\n If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow."
+143 -157
View File
@@ -1,54 +1,32 @@
", please select the option that best describes the current state of your Vault. The application will then check your files in the most appropriate way based on your selection.":
", selecciona la opción que mejor describa el estado actual de tu Vault. La
", selecciona la opción que mejor describa el estado actual de tu bóveda. La
aplicación comprobará entonces tus archivos de la forma más adecuada según tu
elección."
(Active): (Activo)
(BETA) Always overwrite with a newer file: (BETA) Sobrescribir siempre con archivo más nuevo
(Beta) Use ignore files: (Beta) Usar archivos de ignorar
(Beta) Use ignore files: (Beta) Usar archivos ignore
(Days passed, 0 to disable automatic-deletion): (Días transcurridos, 0 para desactivar)
(e.g., after editing many files whilst offline): (p. ej., tras editar muchos archivos sin conexión)
(e.g., after editing many files whilst offline): (p. ej., tras editar muchos archivos estando sin conexión)
(e.g., immediately after restoring on another computer, or having recovered from a backup):
(p. ej., justo después de restaurar en otro ordenador o de recuperar una copia
de seguridad)
(e.g., setting up for the first time on a new smartphone, starting from a clean slate): (p. ej., configurando por primera vez un móvil nuevo, empezando de cero)
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks online directly instead of replicating them locally. Increasing Custom chunk size is recommended.:
"(Ej: Leer chunks online) Lee chunks directamente en línea. Aumente tamaño de
chunks personalizados"
"(Ej: Leer chunks online) Lee chunks directamente en línea. Aumenta tamaño de
chunks personalizados."
(MB) If this is set, changes to local and remote files that are larger than this will be skipped. If the file becomes smaller again, a newer one will be used.:
(MB) Saltar cambios en archivos locales/remotos mayores a este tamaño. Si se
reduce, se usará versión nueva
(MB) Si se activa, se saltarán cambios en archivos locales y remotos mayores a este tamaño. Si tamaño del archivo se reduce
se usará la versión nueva.
(Mega chars): (Millones de caracteres)
(Missing): (Falta)
(Not recommended) If set, credentials will be stored in the file.: (No recomendado) Almacena credenciales en el archivo
(Obsolete) Use an old adapter for compatibility: (Obsoleto) Usar adaptador antiguo
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.:
(RegExp) Déjelo vacío para sincronizar todos los archivos. Defina un filtro
como expresión regular para limitar los archivos que se sincronizan.
(RegExp) Déjalo vacío para sincronizar todos los archivos. Definaeun filtro
como expresión regular para limitar los archivos sincronizados.
(RegExp) If this is set, any changes to local and remote files that match this will be skipped.:
(RegExp) Si se establece, se omitirá cualquier cambio en archivos locales y
remotos que coincida con este patrón.
"Cancel all and reboot": "Cancelar todo y reiniciar"
"Compare time and take newer": "Comparar fechas y usar la más reciente"
"Conflict & Deletion Options": "Opciones de conflictos y eliminación"
"Data retrieval scheduled": "Obtención de datos programada"
"Delete local files if deleted on remote": "Eliminar los archivos locales si se eliminaron en el remoto"
"Delete local files if not on remote": "Eliminar los archivos locales que no estén en el remoto"
"Finalise the process and resume normal operation": "Finalizar el proceso y reanudar el funcionamiento normal"
"How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "¿Cómo se deben gestionar los archivos que se eliminaron en otros dispositivos?\n\n- **${NEWER_CLEANUP}**: elimina los archivos locales si se eliminaron en el remoto.\n Es útil si quieres mantener tu vault limpio y coherente entre dispositivos; asegúrate de tener una copia de seguridad si ya tienes datos importantes en tu vault.\n- **${NEWER_SYNC_ALL}**: vuelve a crear los archivos remotos aunque se hayan eliminado en el remoto.\n Esta opción mantiene todos tus archivos locales, pero puede causar duplicados si hay archivos que existen en local pero no en el remoto. Puedes limpiar esos duplicados manualmente después de sincronizar."
"How to handle extra existing local files?": "¿Cómo gestionar los archivos locales adicionales?"
"I'm ready, unlock the database": "Estoy listo, desbloquear la base de datos"
"I've made a backup, mark this device 'resolved'": "Ya hice una copia de seguridad, marcar este dispositivo como «resuelto»"
"Keep local files even if deleted on remote": "Mantener los archivos locales aunque se hayan eliminado en el remoto"
"Keep local files even if not on remote": "Mantener los archivos locales aunque no estén en el remoto"
"Overwrite all with remote files": "Sobrescribir todo con los archivos remotos"
"Reboot to re-run the process": "Reiniciar para volver a ejecutar el proceso"
"Since you have chosen to overwrite all local files with remote data, **how would you like to handle local files that are not present in the remote database?**\n\n- **${DELETE_ALL}**: Local-only files and remote-deleted files will be removed.\n This option will make your local vault exactly the same as the remote database, but please make sure you have a backup if there is any important data in your vault.\n- **${DELETE_NONE}**: All existing local files will be preserved.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "Como has elegido sobrescribir todos los archivos locales con los datos remotos, **¿cómo quieres gestionar los archivos locales que no existen en la base de datos remota?**\n\n- **${DELETE_ALL}**: se eliminarán los archivos que solo existen en local y los que se han borrado en el remoto.\n Esta opción hará que tu vault local sea exactamente igual que la base de datos remota; asegúrate de tener una copia de seguridad si tienes datos importantes en tu vault.\n- **${DELETE_NONE}**: se conservarán todos los archivos locales existentes.\n Esta opción mantiene todos tus archivos locales, pero puede causar duplicados si hay archivos que existen en local pero no en el remoto. Puedes limpiar esos duplicados manualmente después de sincronizar."
"Some files failed to synchronise. What would you like to do?": "Algunos archivos no se pudieron sincronizar. ¿Qué quieres hacer?"
"Synchronisation Issues Detected": "Se han detectado problemas de sincronización"
"The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication.": "La base de datos remota se ha bloqueado para la sincronización con el fin de evitar la corrupción del vault, porque este dispositivo no está marcado como «resuelto». Haz una copia de seguridad de tu vault, restablece la base de datos local y selecciona «Marcar este dispositivo como resuelto». Este aviso persistirá hasta que la réplica confirme que el dispositivo está resuelto."
"To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication": "Para evitar una corrupción no deseada del vault, la base de datos remota se ha bloqueado para la sincronización. (Este dispositivo está marcado como «resuelto») Cuando todos tus dispositivos estén marcados como «resueltos», desbloquea la base de datos. Este aviso seguirá apareciendo hasta que la réplica confirme que el dispositivo está resuelto"
"Use the detailed flow": "Usar el flujo detallado"
"We are about to retrieve the remote data.\n\nFirstly, how shall we handle the data retrieved from this remote source?\n\n- **${NEWER_WINS}**: Compares the modified time of files and takes the newer one.\n If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.\n- **${REMOTE_WINS}**: Remote data is the source of truth.\n If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.\n It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.\n- **${DETAILED}**: Opens the detailed setup wizard.\n If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow.": "Vamos a obtener los datos remotos.\n\nEn primer lugar, ¿cómo quieres gestionar los datos obtenidos de este origen remoto?\n\n- **${NEWER_WINS}**: compara la fecha de modificación de los archivos y usa la más reciente.\n Si has estado usando Self-hosted LiveSync y has hecho cambios en varios dispositivos, esta opción puede ser adecuada, ya que intenta combinar los cambios según la fecha de modificación.\n- **${REMOTE_WINS}**: los datos remotos son la fuente de la verdad.\n Si eres nuevo en Self-hosted LiveSync, esta opción puede ser la más fácil de entender para empezar.\n Sobrescribirá todos tus archivos locales con los datos remotos, así que asegúrate de tener una copia de seguridad si tienes datos importantes en tu vault.\n- **${DETAILED}**: abre el asistente de configuración detallado.\n Si quieres tener más control sobre el proceso de sincronización, o revisar los cambios antes de aplicarlos, elige esta opción para usar el flujo detallado."
(RegExp) Si se establece, se omitirá cualquier cambio en los archivos locales y
remotos que coincidan con este patrón.
"↑: Overwrite Remote": "↑: Sobrescribir remoto"
"↓: Overwrite Local": "↓: Sobrescribir local"
"⇅: Use newer": "⇅: Usar el más nuevo"
@@ -81,22 +59,22 @@ Add default patterns: Añadir patrones predeterminados
Add new connection: Añadir conexión
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy
inesperada. Informa de este problema.
inesperada. Por favor informa de este problema.
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
El módulo complementario (HiddenFileSync) no se ha cargado. Esta situación es
muy inesperada. Informa de este problema.
muy inesperada. Por favor informa de este problema.
Advanced: Avanzado
Advanced Settings: Ajustes avanzados
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
Tras reiniciar, los datos de este dispositivo se subirán al servidor como
«copia maestra». Ten en cuenta que cualquier dato no deseado que haya ahora en
«copia maestra». Ten en cuenta que cualquier dato en
el servidor se sobrescribirá por completo.
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised files in this vault, conflicts may occur with the server data.:
Tras reiniciar, la base de datos de este dispositivo se reconstruirá con los
datos del servidor. Si hay archivos sin sincronizar en este vault, pueden
datos del servidor. Si hay archivos sin sincronizar en esta bóveda pueden
producirse conflictos con los datos del servidor.
After that, synchronise to a brand new vault on each other device with the new remote one by one.:
Después, sincroniza con un vault totalmente nuevo en cada uno de los demás
Después, sincroniza con una bóveda totalmente nueva en cada uno de los demás
dispositivos, uno por uno, usando el nuevo remoto.
All checks passed successfully!: ¡Todas las comprobaciones se han superado correctamente!
All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.:
@@ -107,10 +85,10 @@ All the same or non-existent: Todo igual o inexistente
Allow in session: Permitir en esta sesión
Allow permanently: Permitir permanentemente
Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future.:
Además, si usas sincronización punto a punto, esta configuración se aplicará
cuando en el futuro cambies a otros métodos y te conectes a un servidor
Además, si usas sincronización punto a punto, esta configuración también se aplicará
en el futuro si cambias a otros métodos de sincronización y te conectas a un servidor
remoto.
Always prompt merge conflicts: Siempre preguntar en conflictos
Always prompt merge conflicts: Siempre preguntar cuando haya conflictos
Analyse: Analizar
Analyse database usage: Analizar el uso de la base de datos
Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.:
@@ -118,7 +96,7 @@ Analyse database usage and generate a TSV report for diagnosis yourself. You can
diagnosticarlo tú mismo. Puedes pegar el informe generado en la hoja de
cálculo que prefieras.
Apply All Selected: Aplicar todo lo seleccionado
Apply Latest Change if Conflicting: Aplicar último cambio en conflictos
Apply Latest Change if Conflicting: Aplicar último cambio si hay conflictos
Apply preset configuration: Aplicar configuración predefinida
Apply the settings: Aplicar los ajustes
Ask a passphrase at every launch: Solicitar la frase de contraseña en cada inicio
@@ -129,13 +107,13 @@ Automatically Sync all files when opening Obsidian.: Sincronizar automáticament
Available Peers: Pares disponibles
Back: Volver
Back to non-configured: Volver a no configurado
Batch database update: Actualización por lotes de BD
Batch database update: Actualización de base de datos por lotes
Batch limit: Límite de lotes
Batch size: Tamaño de lote
Batch size of on-demand fetching: Tamaño de lote para obtención bajo demanda
Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this.:
Antes de v0.17.16 usábamos adaptador antiguo. Nuevo adaptador requiere
reconstruir BD local. Desactive cuando pueda
Antes de v0.17.16 se usaba el adaptador antiguo. El nuevo adaptador requiere
reconstruir la base de datos local. Por favor desactiva esta opción cuando puedas. Si está activa cuando se sincroniza la base de datos se intentará desactivar de nuevo.
Broadcasting?: ¿Difusión?
Bucket Name: Nombre del bucket
by resetting the remote, you will be informed on other devices.: al restablecer el remoto, se te avisará en los demás dispositivos.
@@ -166,7 +144,7 @@ cmdConfigSync:
Comma separated `.gitignore, .dockerignore`: "Separados por comas: `.gitignore, .dockerignore`"
Command: Comando
Communicating: Comunicando
Compaction in progress on remote database...: Compactación en curso en la base de datos remota...
Compaction in progress on remote database...: Compactación en curso de la base de datos remota...
Compaction on remote database completed successfully.: La compactación de la base de datos remota se ha completado correctamente.
Compaction on remote database failed.: La compactación de la base de datos remota ha fallado.
Compaction on remote database timed out.: La compactación de la base de datos remota ha superado el tiempo de espera.
@@ -251,7 +229,7 @@ Diagnostic RTCPeerConnection is enabled: El RTCPeerConnection de diagnóstico es
dialog:
yourLanguageAvailable:
_value: >-
Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha
Self-hosted LiveSync tiene traducciones para tu idioma, así que se ha
activado el ajuste %{Display Language}.
@@ -263,7 +241,7 @@ dialog:
hacerlo desde el diálogo de ajustes.
¡Que lo disfrutes!
Title: " ¡Hay traducción disponible!"
Title: " ¡Hay una traducción disponible!"
btnRevertToDefault: Mantener %{lang-def}
Diff: Diferencias
Different: Distinto
@@ -293,13 +271,13 @@ Doctor:
Por desgracia, se han detectado algunos ajustes como posibles problemas.
Tranquilo: los resolveremos uno a uno.
Para que lo sepas de antemano, te preguntaremos por los siguientes puntos.
Para que lo sepas de antemano, te preguntaremos por lo siguiente.
${issues}
¿Empezamos?
MainFix: |-
## ${name}
| Actual | Ideal |
@@ -325,8 +303,8 @@ Doctor:
Recommended: Recomendado
Message:
NoIssues: ¡No se ha detectado ningún problema!
RebuildLocalRequired: ¡Atención! Hay que reconstruir la base de datos local para aplicar esto.
RebuildRequired: ¡Atención! Hay que reconstruir para aplicar esto.
RebuildLocalRequired: ¡Atención! Hay que reconstruir la base de datos local
RebuildRequired: ¡Atención! Hay que reconstruir la base de datos
SomeSkipped: Hemos dejado algunos problemas sin resolver. ¿Quieres que te lo
pregunte de nuevo en el próximo inicio?
RULES:
@@ -360,11 +338,11 @@ Enabling end-to-end encryption ensures that your data is encrypted on your devic
Activar el cifrado de extremo a extremo garantiza que tus datos se cifren en
tu dispositivo antes de enviarse al servidor remoto. Así, aunque alguien
acceda al servidor, no podrá leer tus datos sin la frase de contraseña.
Recuérdala bien, porque será necesaria para descifrar tus datos en los demás
Recuérdala bien porque será necesaria para descifrar tus datos en los demás
dispositivos.
Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.:
Cifrar contenido en la base de datos remota. Se recomienda habilitar si usa la
sincronización del plugin.
sincronización del complemento.
Encrypting sensitive configuration items: Cifrando elementos sensibles
Encryption Algorithm: Algoritmo de cifrado
Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.:
@@ -399,7 +377,7 @@ Failed to connect to remote for compaction. ${reason}: No se pudo conectar al re
Failed to connect to the server. Please check your settings.: No se pudo conectar al servidor. Revisa tus ajustes.
"Failed to connect to the signalling relay: ${reason}": "No se pudo conectar al relé de señalización: ${reason}"
Failed to create replicator instance.: No se pudo crear la instancia del replicador.
Failed to parse Setup-URI.: No se pudo interpretar el Setup-URI.
Failed to parse Setup-URI.: No se pudo interpretar el Setup URI.
Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.:
No se pudo iniciar la replicación puntual previa a la recolección de basura.
Recolección de basura cancelada.
@@ -407,7 +385,7 @@ Failed to start replication after Garbage Collection.: No se pudo iniciar la rep
"Failed:": "Fallidas:"
Fetch: Obtener
Fetch chunks on demand: Obtener chunks bajo demanda
Fetch database with previous behaviour: Obtener BD con comportamiento anterior
Fetch database with previous behaviour: Obtener base de datos con comportamiento anterior
Fetch remote settings: Obtener ajustes remotos
FETCHING: OBTENIENDO
Fetching status...: Obteniendo el estado...
@@ -423,7 +401,7 @@ Flag and restart: Marcar y reiniciar
Flagged Selective: Selectivo marcado
Folder Prefix: Prefijo de carpeta
For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.:
Para los algoritmos HS256/HS512, indica la clave secreta compartida. Para
Indica la clave secreta compartida para los algoritmos HS256/HS512. Para
ES256/ES512, indica la clave privada en formato PEM pkcs8.
Forces the file to be synced when opened.: Forzar sincronización al abrir archivo
Fresh Start Wipe: Borrado para reinicio completo
@@ -466,9 +444,9 @@ However, This should not be enabled if you want to increase your secrecy more.:
I am setting up a new server for the first time / I want to reset my existing server.:
Estoy configurando un servidor nuevo por primera vez / quiero restablecer mi
servidor actual.
I am unable to create a backup of my Vault.: No puedo crear una copia de seguridad de mi Vault.
I am unable to create a backup of my Vaults.: No puedo crear una copia de seguridad de mis Vaults.
I have created a backup of my Vault.: He creado una copia de seguridad de mi Vault.
I am unable to create a backup of my Vault.: No puedo crear una copia de seguridad de mi bóveda.
I am unable to create a backup of my Vaults.: No puedo crear una copia de seguridad de mis bóvedas.
I have created a backup of my Vault.: He creado una copia de seguridad de mi bóveda.
I understand that all changes made on other smartphones or computers possibly could be lost.:
Entiendo que todos los cambios hechos en otros móviles u ordenadores podrían
perderse.
@@ -479,18 +457,18 @@ I understand that this action is irreversible once performed.: Entiendo que esta
I understand the risks and will proceed without a backup.: Entiendo los riesgos y continuaré sin copia de seguridad.
I Understand, Overwrite Server: Lo entiendo, sobrescribir el servidor
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in launches.:
Si «Iniciar la conexión P2P automáticamente» está activado, la conexión P2P se
Si «Iniciar la conexión P2P automáticamente» está activada, la conexión P2P se
iniciará al arrancar el complemento.
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).: Si se desactiva, chunks se dividen en hilo UI (comportamiento anterior)
If disabled(toggled), chunks will be split on the UI thread (Previous behaviour).: Si se desactiva, los chunks se dividen en el hilo de UI (comportamiento anterior)
If enabled per-filed efficient customization sync will be used. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost a compatibility with old versions.:
Habilita sincronización eficiente por archivo. Requiere migración y actualizar
todos dispositivos a v0.23.18. Pierde compatibilidad con versiones antiguas
todos los dispositivos a v0.23.18. Pierde compatibilidad con versiones antiguas.
If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.: Divide chunks en máximo 100 ítems. Menos eficiente en deduplicación
If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.: Chunks nuevos se mantienen temporalmente en el documento hasta estabilizarse
If enabled, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised.: Chunks nuevos se mantienen temporalmente en el documento hasta estabilizarse.
If enabled, the ⛔ icon will be shown inside the status instead of the file warnings banner. No details will be shown.:
Si se activa, se mostrará el icono ⛔ en el estado en lugar del banner de
advertencia de archivos. No se mostrarán detalles.
If enabled, the file under 1kb will be processed in the UI thread.: Archivos <1kb se procesan en hilo UI
If enabled, the file under 1kb will be processed in the UI thread.: Archivos <1kb se procesan en el hilo de UI
If enabled, the notification of hidden files change will be suppressed.: Si se habilita, se suprimirá la notificación de cambios en archivos ocultos.
If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour):
Si se habilita, todos los chunks se almacenan con la revisión hecha desde su
@@ -498,13 +476,13 @@ If this enabled, all chunks will be stored with the revision made from its conte
If this enabled, All files are handled as case-Sensitive (Previous behaviour).:
Si se habilita, todos los archivos se manejan como sensibles a mayúsculas
(comportamiento anterior)
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.: Divide chunks en segmentos semánticos. No todos los sistemas lo soportan
If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.: Divide chunks en segmentos semánticos. No todos los sistemas lo soportan.
If this is set, changes to local files which are matched by the ignore files will be skipped. Remote changes are determined using local ignore files.:
Saltar cambios en archivos locales que coincidan con ignore files. Cambios
remotos usan ignore files locales
remotos usan ignore files locales.
If this option is enabled, PouchDB will hold the connection open for 60 seconds, and if no change arrives in that time, close and reopen the socket, instead of holding it open indefinitely. Useful when a proxy limits request duration but can increase resource usage.:
Mantiene conexión 60s. Si no hay cambios, reinicia socket. Útil con proxies
limitantes
Mantiene una conexión abierta por 60s en CouchDB. Si no hay cambios, se reinicia socket en vez de dejarlo abierto de forma indefinida. Útil con proxies
limitantes pero puede aumentar el uso de recursos.
If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate with the CouchDB server. Not compliant with web standards, but works. Note that this might break in future Obsidian versions.:
Si no puedes evitar problemas de CORS, prueba esta opción. Usa la API interna
de Obsidian para comunicarse con el servidor CouchDB. No cumple los estándares
@@ -516,11 +494,11 @@ If you cannot avoid CORS issues, you might want to try this option. It uses Obsi
pero funciona. Ten en cuenta que podría dejar de funcionar en versiones
futuras de Obsidian.
If you have unsynchronised changes in your Vault on this device, they will likely diverge from the server's versions after the reset. This may result in a large number of file conflicts.:
Si tienes cambios sin sincronizar en el Vault de este dispositivo, es probable
que divergan de las versiones del servidor tras el restablecimiento. Esto
Si tienes cambios sin sincronizar en la bóveda de este dispositivo, es probable
que diverjan de las versiones del servidor tras el restablecimiento. Esto
puede provocar un gran número de conflictos de archivos.
If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.:
Si alcanzas el límite de tamaño de carga al usar IBM Cloudant, reduce el
Si alcanzas el límite de tamaño de carga al usar IBM Cloudant, por favor reduce el
tamaño de lote y el límite de lote.
If you understand the risks and still wish to proceed, select so.: Si entiendes los riesgos y aun así quieres continuar, indícalo.
If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here. Otherwise, leave it blank to store data at the root of the bucket.:
@@ -538,7 +516,7 @@ Import connection: Importar conexión
IMPORTANT: IMPORTANTE
In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.:
En la mayoría de los casos deberías mantener el algoritmo predeterminado
(${algorithm}). Este ajuste solo es necesario si ya tienes un Vault cifrado en
(${algorithm}). Este ajuste solo es necesario si ya tienes una bóveda cifrada en
otro formato.
In this scenario, Self-hosted LiveSync will recreate metadata for every file and deliberately generate conflicts. Where the file content is identical, these conflicts will be resolved automatically.:
En este caso, Self-hosted LiveSync recreará los metadatos de todos los
@@ -734,12 +712,12 @@ moduleInputUIObsidian:
optionNo: No
optionYes: Sí
moduleLiveSyncMain:
logAdditionalSafetyScan: Escanéo de seguridad adicional...
logAdditionalSafetyScan: Escaneo de seguridad adicional...
logLoadingPlugin: Cargando complemento...
logPluginInitCancelled: La inicialización del complemento fue cancelada por un módulo
logPluginVersion: Self-hosted LiveSync v${manifestVersion} ${packageVersion}
logReadChangelog: LiveSync se ha actualizado, ¡por favor lee el registro de cambios!
logSafetyScanCompleted: Escanéo de seguridad adicional completado
logSafetyScanCompleted: Escaneo de seguridad adicional completado
logSafetyScanFailed: El escaneo de seguridad adicional ha fallado en un módulo
logUnloadingPlugin: Descargando complemento...
logVersionUpdate: LiveSync se ha actualizado, en caso de actualizaciones que
@@ -771,7 +749,7 @@ moduleLiveSyncMain:
o se obtiene. Si el proceso termina de forma anormal, puede mantenerse sin
querer.
> Si no estás seguro, puedes intentar volver a ejecutar estos procesos.
> Si no estás seguro puedes intentar volver a ejecutar estos procesos.
Asegúrate de hacer una copia de seguridad de tu bóveda.
optionKeepLiveSyncDisabled: Mantener LiveSync desactivado
optionResumeAndRestart: Reanudar y reiniciar Obsidian
@@ -907,7 +885,7 @@ moduleMigration:
Nota: actualiza Self-hosted LiveSync a la v0.25.6 o superior en todos tus
dispositivos y haz una copia de seguridad fiable de tu vault.
dispositivos y haz una copia de seguridad fiable de tu bóveda.
Nota 2: reconstruir todo y obtener los datos consume algo de tiempo y de
tráfico; hazlo en horas de poco uso y con una conexión de red estable.
@@ -956,7 +934,7 @@ On the source device, from the command palette, run the 'Show settings as a QR c
En el dispositivo de origen, ejecuta desde la paleta de comandos «Show
settings as a QR code».
On the source device, open Obsidian.: En el dispositivo de origen, abre Obsidian.
On this device, please keep this Vault open.: En este dispositivo, mantén este Vault abierto.
On this device, please keep this Vault open.: En este dispositivo, mantén esta bóveda abierta.
On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.:
En este dispositivo, cambia a la aplicación de cámara o usa un lector de QR
para escanear el código mostrado.
@@ -978,7 +956,7 @@ P2P:
Note:
Summary: ¿Qué es esta función? (incluye notas importantes; léelas al menos una vez)
description: >2-
Este replicador permite sincronizar el vault con otros dispositivos
Este replicador permite sincronizar la bóveda con otros dispositivos
mediante una conexión punto a punto. Así se puede sincronizar con nuestros
otros dispositivos sin usar un servicio en la nube.
@@ -1037,7 +1015,7 @@ Please enter the details required to connect to your S3/MinIO/R2 compatible obje
Please enter the Peer-to-Peer Synchronisation information below.: Introduce a continuación los datos de la sincronización punto a punto.
Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.:
Introduce el Setup URI generado durante la instalación del servidor o en otro
dispositivo, junto con la frase de contraseña del vault.
dispositivo, junto con la frase de contraseña de la bóveda.
Please follow the steps below to import settings from your existing device.:
Sigue los pasos siguientes para importar los ajustes desde tu dispositivo
actual.
@@ -1281,7 +1259,7 @@ Setup:
LabelEncryptionAlgorithm: Algoritmo de cifrado
DefaultAlgorithmDesc: En la mayoría de los casos, debes mantener el algoritmo
predeterminado (${algorithm}). Este ajuste solo es necesario si ya tienes
un Vault cifrado con un formato diferente.
una bóveda cifrada con un formato diferente.
AlgorithmWarning: Cambiar el algoritmo de cifrado impedirá el acceso a cualquier
dato cifrado anteriormente con otro algoritmo. Asegúrate de que todos tus
dispositivos estén configurados para usar el mismo algoritmo y así
@@ -1300,16 +1278,16 @@ Setup:
Title: Introducir URI de configuración
GuidanceLine1: Introduce la URI de configuración que se generó durante la
instalación del servidor o en otro dispositivo, junto con la frase de
contraseña del Vault.
contraseña de la bóveda.
GuidanceLine2: Ten en cuenta que puedes generar una nueva URI de configuración
ejecutando el comando "Copiar ajustes como nueva URI de configuración"
desde la paleta de comandos.
LabelSetupURI: URI de configuración
ValidInfo: La URI de configuración es válida y está lista para usarse.
InvalidInfo: La URI de configuración no es válida. Revísala e inténtalo de nuevo.
LabelPassphrase: Frase de contraseña del Vault
PlaceholderPassphrase: Introduce la frase de contraseña del Vault
ErrorPassphraseRequired: Introduce la frase de contraseña del Vault.
LabelPassphrase: Frase de contraseña de la bóveda
PlaceholderPassphrase: Introduce la frase de contraseña de la bóveda
ErrorPassphraseRequired: Introduce la frase de contraseña de la bóveda.
ErrorFailedToParse: No se pudo procesar la URI de configuración. Revisa la URI y
la frase de contraseña.
ButtonProceed: Probar ajustes y continuar
@@ -1318,7 +1296,7 @@ Setup:
Title: Escanear código QR
Guidance: Sigue los pasos de abajo para importar los ajustes desde tu
dispositivo actual.
Step1: En este dispositivo, mantén este Vault abierto.
Step1: En este dispositivo, mantén esta bóveda abierta.
Step2: En el dispositivo de origen, abre Obsidian.
Step3: En el dispositivo de origen, ejecuta desde la paleta de comandos la orden
"Mostrar ajustes como código QR".
@@ -1581,7 +1559,7 @@ obsidianLiveSyncSettingTab:
cifrado?
msgFetchConfigFromRemote: ¿Quieres obtener la configuración del servidor remoto?
msgIfConfigNotPersistent: Si la configuración del servidor no es persistente
(por ejemplo, ejecutándose en docker), los valores aquí pueden cambiar. Una
(por ejemplo, ejecutándose en Docker), los valores aquí pueden cambiar. Una
vez que puedas conectarte, por favor actualiza las configuraciones en el
local.ini del servidor.
msgInvalidPassphrase: Tu frase de contraseña de cifrado podría ser inválida.
@@ -1691,14 +1669,14 @@ obsidianLiveSyncSettingTab:
okAdminPrivileges: ✔ Tienes privilegios de administrador.
okCorsCredentials: ✔ cors.credentials está correcto.
okCorsCredentialsForOrigin: CORS credenciales OK
okCorsOriginMatched: ✔ Origen de CORS correcto
okCorsOrigins: ✔ cors.origins está correcto.
okEnableCors: ✔ httpd.enable_cors está correcto.
okMaxDocumentSize: ✔ couchdb.max_document_size está correcto.
okMaxRequestSize: ✔ chttpd.max_http_request_size está correcto.
okRequireValidUser: ✔ chttpd.require_valid_user está correcto.
okRequireValidUserAuth: ✔ chttpd_auth.require_valid_user está correcto.
okWwwAuth: ✔ httpd.WWW-Authenticate está correcto.
okCorsOriginMatched: ✔ Origen de CORS es correcto
okCorsOrigins: ✔ cors.origins es correcto.
okEnableCors: ✔ httpd.enable_cors es correcto.
okMaxDocumentSize: ✔ couchdb.max_document_size es correcto.
okMaxRequestSize: ✔ chttpd.max_http_request_size es correcto.
okRequireValidUser: ✔ chttpd.require_valid_user es correcto.
okRequireValidUserAuth: ✔ chttpd_auth.require_valid_user es correcto.
okWwwAuth: ✔ httpd.WWW-Authenticate es correcto.
optionApply: Aplicar
optionCancel: Cancelar
optionCouchDB: CouchDB
@@ -1711,7 +1689,7 @@ obsidianLiveSyncSettingTab:
optionOnEvents: En eventos
optionPeriodicAndEvents: Periódico y en eventos
optionPeriodicWithBatch: Periódico con lote
optionRebuildBoth: Reconstructuir ambos desde este dispositivo
optionRebuildBoth: Reconstruir ambos desde este dispositivo
optionSaveOnlySettings: (Peligro) Guardar solo configuración
panelChangeLog: Registro de cambios
panelGeneralSettings: Configuraciones generales
@@ -1800,7 +1778,7 @@ Rebuild local and remote database with local files.: Reconstruye la base de dato
Rebuilding Operations (Remote Only): Operaciones de reconstrucción (solo remoto)
Recreate all: Recrear todo
Recreate missing chunks for all files: Recrear fragmentos faltantes para todos los archivos
Reducing the frequency with which on-disk changes are reflected into the DB: Reducir frecuencia de actualizaciones de disco a BD
Reducing the frequency with which on-disk changes are reflected into the DB: Reducir frecuencia de actualizaciones de disco a base de datos
Region: Región
Remediation: Remediación
Remediation Setting Changed: La configuración de remediación cambió
@@ -1811,7 +1789,7 @@ Remote server type: Tipo de servidor remoto
Remote Type: Tipo de remoto
Rename: Renombrar
Requires restart of Obsidian: Requiere reiniciar Obsidian
Requires restart of Obsidian.: Requiere reiniciar Obsidian
Requires restart of Obsidian.: Requiere reiniciar Obsidian.
Resend: Reenviar
Resend all chunks to the remote.: Reenvía todos los chunks al remoto.
Reset: Restablecer
@@ -1850,10 +1828,10 @@ Schedule and Restart: Programar y reiniciar
Scram!: Medidas de emergencia
Seconds, 0 to disable: Segundos, 0 para desactivar
Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.:
Segundos. Guardado en BD local se retrasará hasta este valor tras dejar de
escribir/guardar
Segundos. Guardado en base de datos local se retrasará hasta este valor tras dejar de
escribir o guardar.
Secret Key: Clave secreta
Select the database adapter to use.: Selecciona el adaptador de base de datos que se usará.
Select the database adapter to use.: Selecciona el adaptador de base de datos.
Send: Enviar
Send chunks: Enviar chunks
Server URI: URI del servidor
@@ -1969,7 +1947,7 @@ Stat: Estado
Stats: Estadísticas
Stop ⚡: Detener ⚡
Stop Broadcasting: Detener difusión
Stop reflecting database changes to storage files.: Dejar de reflejar cambios de BD en archivos
Stop reflecting database changes to storage files.: Dejar de reflejar cambios de base de datos en archivos
Stop watching for file changes.: Dejar de monitorear cambios en archivos
Storage -> Database: Almacenamiento -> Base de datos
Strongly Recommended: Muy recomendado
@@ -2003,7 +1981,7 @@ The connection to the server has been configured successfully. As the next step,
La conexión con el servidor se ha configurado correctamente. Como paso
siguiente,
The delay for consecutive on-demand fetches: Retraso entre obtenciones consecutivas
The files in this Vault are almost identical to the server's.: Los archivos de este Vault son casi idénticos a los del servidor.
The files in this Vault are almost identical to the server's.: Los archivos de esta bóveda son casi idénticos a los del servidor.
"The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": >-
A los siguientes nodos aceptados les falta su información de nodo:
@@ -2048,7 +2026,7 @@ the synchronisation data on the server will be built based on the current data o
actuales de este dispositivo.
Themes: Temas
There is a way to resolve this on other devices.: Hay una forma de resolver esto en los demás dispositivos.
There may be differences between the files in this Vault and the server.: Puede haber diferencias entre los archivos de este Vault y los del servidor.
There may be differences between the files in this Vault and the server.: Puede haber diferencias entre los archivos de esta bóveda y los del servidor.
Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted.:
Por tanto, extrema la precaución al configurar manualmente los datos del
servidor. Si introduces una frase de contraseña incorrecta, los datos del
@@ -2064,23 +2042,23 @@ This first setup has several short steps because it confirms encryption, the con
Una vez completada, los demás dispositivos podrán reutilizar un Setup URI.
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
Esta es una operación extremadamente potente. Te recomendamos encarecidamente
copiar la carpeta de tu Vault a un lugar seguro.
copiar la carpeta de tu bóveda a un lugar seguro.
This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required.:
Esta comprobación opcional usa la API interna de solicitudes de Obsidian y
envía las credenciales anteriores al servidor CouchDB. Utilízala solo con un
servidor de confianza; puede requerir acceso de administrador.
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.: Esta frase no se copia a otros dispositivos. Usará `Default` hasta reconfigurar
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.: Esta frase no se copia a otros dispositivos. Usará `Default` hasta que lo configures de nuevo.
This password is used to encrypt the connection. Use something long enough.: Esta contraseña se usa para cifrar la conexión. Usa algo suficientemente largo.
This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as:
Este procedimiento eliminará primero todos los datos de sincronización
existentes en el servidor. A continuación, los datos del servidor se
reconstruirán por completo usando el estado actual del Vault de este
reconstruirán por completo usando el estado actual de la bóveda de este
dispositivo (incluida su base de datos local) como
This setting must be the same even when connecting to multiple synchronisation destinations.:
Este ajuste debe ser el mismo incluso si te conectas a varios destinos de
sincronización.
This Vault is empty, or contains only new files that are not on the server.:
Este Vault está vacío o solo contiene archivos nuevos que no están en el
Esta bóveda está vacía o solo contiene archivos nuevos que no están en el
servidor.
This will rebuild the local database on this device using the most recent data from the server. This action is designed to resolve synchronisation inconsistencies and restore correct functionality.:
Esto reconstruirá la base de datos local de este dispositivo con los datos más
@@ -2112,13 +2090,13 @@ TweakMismatchResolve:
UseRemoteWithRebuild: Aplicar los ajustes a este dispositivo y volver a obtener los datos
Message:
AutoAcceptCompatibleUndefined: >-
Parece que los ajustes son distintos en cada dispositivo. Ahora se pueden
aplicar automáticamente los cambios compatibles a estas configuraciones.
¿Quieres activar la aceptación automática (`auto-accept`)?
Main: >-
Los ajustes de la base de datos remota son los siguientes. Los han
configurado otros dispositivos que se han sincronizado con este al menos
una vez.
@@ -2153,7 +2131,7 @@ TweakMismatchResolve:
${additionalMessage}
UseRemote:
WarningRebuildRecommended: >-
>[!NOTICE]
> Algunos cambios son compatibles, pero pueden consumir almacenamiento y
@@ -2162,7 +2140,7 @@ TweakMismatchResolve:
> ***Asegúrate de tener tiempo y una red estable antes de aplicarlo.***
WarningRebuildRequired: >-
>[!WARNING]
> Algunas configuraciones remotas no son compatibles con la base de
@@ -2170,7 +2148,7 @@ TweakMismatchResolve:
> ***Asegúrate de tener tiempo y una red estable antes de aplicarlo.***
WarningIncompatibleRebuildRecommended: >-
>[!NOTICE]
> Hemos detectado que algunos valores difieren de forma que hacen
@@ -2183,7 +2161,7 @@ TweakMismatchResolve:
> Si decides reconstruir, tardará unos minutos o más. **Asegúrate de que
es seguro hacerlo ahora.**
WarningIncompatibleRebuildRequired: >-
>[!WARNING]
> Hemos detectado que algunos valores difieren de forma que hacen
@@ -2337,20 +2315,6 @@ Ui:
Scram: ¡Parada de emergencia!
SendChunks: Enviar los chunks
Syncing: Sincronización
WarningLockedReadyAction: Estoy listo, desbloquear la base de datos
WarningLockedReadyText: Para evitar que el vault se corrompa, la base de datos
remota se ha bloqueado para la sincronización. (Este dispositivo está
marcado como «resuelto».) Cuando todos tus dispositivos estén marcados
como «resueltos», desbloquea la base de datos. Este aviso seguirá
apareciendo hasta que la replicación confirme que el dispositivo está
resuelto.
WarningLockedResolveAction: He hecho una copia de seguridad, marcar este dispositivo como resuelto
WarningLockedResolveText: La base de datos remota está bloqueada para la
sincronización a fin de evitar que el vault se corrompa, porque este
dispositivo no está marcado como «resuelto». Haz una copia de seguridad
de tu vault, restablece la base de datos local y selecciona «Marcar este
dispositivo como resuelto». Este aviso seguirá apareciendo hasta que la
replicación confirme que el dispositivo está resuelto.
WriteRedFlagAndRestart: Marcar y reiniciar
Patches:
CompatibilityConflict: Compatibilidad (comportamiento ante conflictos)
@@ -2388,7 +2352,7 @@ Ui:
RemediationNoLimit: Sin límite configurado
RemediationRestartLater: Más tarde
RemediationRestartMessage: Se recomienda encarecidamente reiniciar Obsidian.
Hasta que lo hagas, puede que algunos cambios no surtan efecto y que la
Hasta que lo hagas puede que algunos cambios no surtan efecto y que la
interfaz se muestre de forma inconsistente. ¿Seguro que quieres
reiniciar ahora?
RemediationRestartNow: Reiniciar ahora
@@ -2421,7 +2385,7 @@ Ui:
DisplayName: Nombre visible
DuplicateRemote: Duplicar el remoto
DuplicateRemoteSuffix: ${name} (copia)
E2EEConfiguration: Configuración del E2EE
E2EEConfiguration: Configuración de E2EE
Export: Exportar
FetchRemoteSettings: Obtener los ajustes remotos
ImportConnection: Importar conexión
@@ -2515,7 +2479,7 @@ Ui:
dispositivo obtenga sus datos iniciales.
GuidancePrimary: La conexión punto a punto se ha configurado correctamente. A
continuación, la base de datos local de LiveSync se construirá a partir
de los archivos actuales de este Vault.
de los archivos actuales de esta bóveda.
Important: TEN EN CUENTA
Proceed: Reiniciar y preparar este dispositivo
Question: Pulsa el botón de abajo para reiniciar y pasar a la confirmación de la
@@ -2535,13 +2499,13 @@ Ui:
RebuildEverythingP2P:
ConfirmLocalReset: Entiendo que esto restablece únicamente la base de datos de
sincronización local de este dispositivo.
ConfirmLocalResetNote: Se usarán los archivos que hay ahora en este Vault para reconstruirla.
ConfirmLocalResetNote: Se usarán los archivos que hay ahora en esta bóveda para reconstruirla.
ConfirmTitle: ⚠️ Confirma lo siguiente
Guidance: Este procedimiento descartará la base de datos local de LiveSync de
este dispositivo y la reconstruirá a partir de los archivos actuales de
este Vault. No elimina ni sobrescribe datos de otro dispositivo.
esta bóveda. No elimina ni sobrescribe datos de otro dispositivo.
Note: Mantén este dispositivo conectado después de la inicialización para que
otro dispositivo pueda obtener el Vault desde él.
otro dispositivo pueda obtener la bóveda desde él.
Proceed: Lo entiendo, preparar este dispositivo
Title: "Confirmación final: preparar este dispositivo para P2P"
SelectExisting:
@@ -2582,7 +2546,7 @@ Ui:
un servicio de almacenamiento de objetos compatible con S3, como Amazon
S3, MinIO o Cloudflare R2.
CouchDbOptionDesc: Es el método de sincronización más adecuado para el diseño
actual y ofrece todas las funciones. Necesitas tener ya una instancia de
actual y ofrece todas las funciones. Necesitas tener una instancia de
CouchDB en marcha.
Guidance: Selecciona el tipo de remoto para esta configuración de sincronización.
P2POption: Punto a punto (P2P)
@@ -2633,8 +2597,8 @@ We cannot change the device name while this feature is enabled. Please disable t
habilitada. Deshabilita la función para cambiarlo.
We have to configure the device name: Hay que configurar el nombre del dispositivo
We recommend that you copy your Vault folder to a safe location. This will provide a safeguard in case a large number of conflicts arise, or if you accidentally synchronise with an incorrect destination.:
Te recomendamos copiar la carpeta de tu Vault a un lugar seguro. Así tendrás
una salvaguarda en caso de que surjan muchos conflictos o de que sincronices
Te recomendamos copiar la carpeta de tu bóveda a un lugar seguro. Así tendrás
una copia de seguridad en caso de que surjan muchos conflictos o de que sincronices
por error con un destino incorrecto.
When you save a file in the editor, start a sync automatically: Iniciar sincronización automática al guardar en editor
Write credentials in the file: Escribir credenciales en archivo
@@ -2643,60 +2607,60 @@ xxhash32 (Fast but less collision resistance): xxhash32 (rápido, pero con menor
xxhash64 (Fastest): xxhash64 (el más rápido)
"Welcome to Self-hosted LiveSync": "Bienvenido a Self-hosted LiveSync"
"We will now guide you through a few questions to simplify the synchronisation setup.":
"Ahora le guiaremos con unas pocas preguntas para simplificar la configuración
"Ahora te guiaremos con unas pocas preguntas para simplificar la configuración
de la sincronización。"
"First, please select the option that best describes your current situation.": "Primero, seleccione la opción que describa mejor su situación actual。"
"First, please select the option that best describes your current situation.": "Primero, seleccione la opción que mejor describa tu situación actual。"
"I am setting this up for the first time": "Estoy configurando esto por primera vez"
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.":
"(Seleccione esto si está configurando este dispositivo como el primer
dispositivo de sincronización). Esta opción es adecuada si es nuevo en
LiveSync y desea configurarlo desde cero。"
"(Selecciona esto si estás configurando este dispositivo como el primer
dispositivo de sincronización). Esta opción es adecuada si eres nuevo en
LiveSync y quieres configurarlo desde cero。"
"I am adding a device to an existing synchronisation setup":
"Estoy agregando un dispositivo a una configuración de sincronización
existente"
"(Select this if another device is already using LiveSync.) This option adds this device to that existing synchronisation setup.":
"(Seleccione esto si ya utiliza la sincronización en otro ordenador o
teléfono). Esta opción es adecuada si desea añadir este dispositivo a una
"(Seleccione esto si ya utilizas la sincronización en otro ordenador o
teléfono). Esta opción es adecuada si quieres añadir este dispositivo a una
configuración de LiveSync existente。"
"Yes, I want to set up a new synchronisation": "Sí, quiero configurar una nueva sincronización"
"Yes, I want to add this device to my existing synchronisation": "Sí, quiero añadir este dispositivo a mi sincronización existente"
"No, please take me back": "No, volver atrás"
"Device Setup Method": "Método de configuración del dispositivo"
"You are adding this device to an existing synchronisation setup.":
"Está añadiendo este dispositivo a una configuración de sincronización
"Estás añadiendo este dispositivo a una configuración de sincronización
existente。"
"Please select a method to import the settings from another device.": "Seleccione un método para importar la configuración desde otro dispositivo。"
"Use a Setup URI (Recommended)": "Usar un URI de configuración (recomendado)"
"Paste the Setup URI generated from one of your active devices.": "Pegue el URI de configuración generado desde uno de sus dispositivos activos。"
"Please select a method to import the settings from another device.": "Selecciona un método para importar la configuración desde otro dispositivo。"
"Use a Setup URI (Recommended)": "Usar URI de configuración (recomendado)"
"Paste the Setup URI generated from one of your active devices.": "Pega el URI de configuración generado desde uno de tus dispositivos activos。"
"Scan a QR Code (Recommended for mobile)": "Escanear un código QR (recomendado para móviles)"
"Scan the QR code displayed on an active device using this device's camera.":
"Escanee con la cámara de este dispositivo el código QR mostrado en un
"Escanea con la cámara de este dispositivo el código QR mostrado en un
dispositivo activo。"
"Enter the server information manually": "Introducir manualmente la información del servidor"
"Configure the same server information as your other devices again, manually, very advanced users only.":
"Configure manualmente la misma información del servidor que en sus otros
"Configura manualmente la misma información del servidor que en sus otros
dispositivos. Solo para usuarios muy avanzados。"
"Proceed with Setup URI": "Continuar con el URI de configuración"
"I know my server details, let me enter them": "Conozco los datos de mi servidor; permítame introducirlos"
"Please select an option to proceed": "Seleccione una opción para continuar"
"I know my server details, let me enter them": "Conozco los datos de mi servidor; déjame introducirlos"
"Please select an option to proceed": "Selecciona una opción para continuar"
"Connection Method": "Método de conexión"
"We will now proceed with the server configuration.": "Ahora continuaremos con la configuración del servidor。"
"How would you like to configure the connection to your server?": "¿Cómo desea configurar la conexión con su servidor?"
"How would you like to configure the connection to your server?": "¿Cómo quieres configurar la conexión con tu servidor?"
"A Setup URI is a single string of text containing your server address and authentication details. Using a URI, if one was generated by your server installation script, provides a simple and secure configuration.":
"Un URI de configuración es una única cadena de texto que contiene la
dirección del servidor y los datos de autenticación. Si el script de
instalación de su servidor generó un URI, usarlo proporciona una configuración
instalación de tu servidor generó un URI, usarla proporciona una configuración
sencilla y segura。"
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.":
"Esta es una opción avanzada para usuarios que no disponen de un URI o que
desean configurar parámetros detallados。"
"Enter Server Information": "Introducir información del servidor"
"Please select the type of server to which you are connecting.": "Seleccione el tipo de servidor al que se está conectando。"
"Please select the type of server to which you are connecting.": "Selecciona el tipo de servidor al que se está conectando。"
"Continue to CouchDB setup": "Continuar con la configuración de CouchDB"
"Continue to S3/MinIO/R2 setup": "Continuar con la configuración de S3/MinIO/R2"
"Continue to Peer-to-Peer only setup": "Continuar con la configuración solo Peer-to-Peer"
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.":
"Este es el método de sincronización más adecuado para el diseño. Todas las
"Este es el método de sincronización más adecuado para el diseño del complemento. Todas las
funciones están disponibles. Debe tener configurada una instancia de CouchDB。"
"S3/MinIO/R2 Object Storage": "Almacenamiento de objetos S3/MinIO/R2"
"Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.":
@@ -2716,4 +2680,26 @@ You should perform this operation only in exceptional circumstances, such as whe
"Solo deberías realizar esta operación en circunstancias excepcionales: cuando
los datos del servidor estén completamente corruptos, cuando ya no necesites
los cambios de los demás dispositivos o cuando el tamaño de la base de datos
sea inusualmente grande respecto al del Vault."
sea inusualmente grande respecto a la bóveda."
"I am ready, unlock the database": "Estoy listo, desbloquear la base de datos"
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "Para evitar que la bóveda se corrompa, la base de datos remota se ha bloqueado para la sincronización. (Este dispositivo está marcado como «resuelto».) Cuando todos tus dispositivos estén marcados como «resueltos», desbloquea la base de datos. Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto."
"I have made a backup, mark this device as resolved": "He hecho una copia de seguridad, marcar este dispositivo como resuelto"
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "La base de datos remota está bloqueada para la sincronización a fin de evitar que la bóveda se corrompa, porque este dispositivo no está marcado como «resuelto». Haz una copia de seguridad de tu bóveda, restablece la base de datos local y selecciona «Marcar este dispositivo como resuelto». Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto."
"Cancel all and reboot": "Cancelar todo y reiniciar"
"Compare time and take newer": "Comparar fechas y usar la más reciente"
"Conflict & Deletion Options": "Opciones de conflictos y eliminación"
"Data retrieval scheduled": "Obtención de datos programada"
"Delete local files if deleted on remote": "Eliminar los archivos locales si se eliminaron en el remoto"
"Delete local files if not on remote": "Eliminar los archivos locales que no estén en el remoto"
"Finalise the process and resume normal operation": "Finalizar el proceso y reanudar el funcionamiento normal"
"How should files that were deleted on other devices be handled?\n\n- **${NEWER_CLEANUP}**: Delete local files if they were deleted on remote.\n This is useful if you want to keep your vault clean and consistent across devices, but please make sure you have a backup if there is already any important data in your vault.\n- **${NEWER_SYNC_ALL}**: Recreate remote files even if they were deleted on remote.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "¿Cómo se deben gestionar los archivos que se eliminaron en otros dispositivos?\n\n- **${NEWER_CLEANUP}**: elimina los archivos locales si se eliminaron en el remoto.\n Es útil si quieres mantener tu vault limpio y coherente entre dispositivos; asegúrate de tener una copia de seguridad si ya tienes datos importantes en tu vault.\n- **${NEWER_SYNC_ALL}**: vuelve a crear los archivos remotos aunque se hayan eliminado en el remoto.\n Esta opción mantiene todos tus archivos locales, pero puede causar duplicados si hay archivos que existen en local pero no en el remoto. Puedes limpiar esos duplicados manualmente después de sincronizar."
"How to handle extra existing local files?": "¿Cómo gestionar los archivos locales adicionales?"
"Keep local files even if deleted on remote": "Mantener los archivos locales aunque se hayan eliminado en el remoto"
"Keep local files even if not on remote": "Mantener los archivos locales aunque no estén en el remoto"
"Overwrite all with remote files": "Sobrescribir todo con los archivos remotos"
"Reboot to re-run the process": "Reiniciar para volver a ejecutar el proceso"
"Since you have chosen to overwrite all local files with remote data, **how would you like to handle local files that are not present in the remote database?**\n\n- **${DELETE_ALL}**: Local-only files and remote-deleted files will be removed.\n This option will make your local vault exactly the same as the remote database, but please make sure you have a backup if there is any important data in your vault.\n- **${DELETE_NONE}**: All existing local files will be preserved.\n This option will keep all your local files, but it may cause duplicates if there are files that exist on local but not on remote. You can clean up these duplicates manually after the synchronisation.": "Como has elegido sobrescribir todos los archivos locales con los datos remotos, **¿cómo quieres gestionar los archivos locales que no existen en la base de datos remota?**\n\n- **${DELETE_ALL}**: se eliminarán los archivos que solo existen en local y los que se han borrado en el remoto.\n Esta opción hará que tu vault local sea exactamente igual que la base de datos remota; asegúrate de tener una copia de seguridad si tienes datos importantes en tu vault.\n- **${DELETE_NONE}**: se conservarán todos los archivos locales existentes.\n Esta opción mantiene todos tus archivos locales, pero puede causar duplicados si hay archivos que existen en local pero no en el remoto. Puedes limpiar esos duplicados manualmente después de sincronizar."
"Some files failed to synchronise. What would you like to do?": "Algunos archivos no se pudieron sincronizar. ¿Qué quieres hacer?"
"Synchronisation Issues Detected": "Se han detectado problemas de sincronización"
"Use the detailed flow": "Usar el flujo detallado"
"We are about to retrieve the remote data.\n\nFirstly, how shall we handle the data retrieved from this remote source?\n\n- **${NEWER_WINS}**: Compares the modified time of files and takes the newer one.\n If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time.\n- **${REMOTE_WINS}**: Remote data is the source of truth.\n If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with.\n It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault.\n- **${DETAILED}**: Opens the detailed setup wizard.\n If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow.": "Vamos a obtener los datos remotos.\n\nEn primer lugar, ¿cómo quieres gestionar los datos obtenidos de este origen remoto?\n\n- **${NEWER_WINS}**: compara la fecha de modificación de los archivos y usa la más reciente.\n Si has estado usando Self-hosted LiveSync y has hecho cambios en varios dispositivos, esta opción puede ser adecuada, ya que intenta combinar los cambios según la fecha de modificación.\n- **${REMOTE_WINS}**: los datos remotos son la fuente de la verdad.\n Si eres nuevo en Self-hosted LiveSync, esta opción puede ser la más fácil de entender para empezar.\n Sobrescribirá todos tus archivos locales con los datos remotos, así que asegúrate de tener una copia de seguridad si tienes datos importantes en tu vault.\n- **${DETAILED}**: abre el asistente de configuración detallado.\n Si quieres tener más control sobre el proceso de sincronización, o revisar los cambios antes de aplicarlos, elige esta opción para usar el flujo detallado."
+4 -4
View File
@@ -1609,10 +1609,6 @@ Ui:
Scram: 긴급 정지
SendChunks: 청크 보내기
Syncing: 동기화
WarningLockedReadyAction: 준비되었습니다. 데이터베이스 잠금 해제
WarningLockedReadyText: 의도치 않은 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. (이 기기는 '해결됨'으로 표시되어 있습니다.) 모든 기기가 '해결됨'으로 표시되면 데이터베이스 잠금을 해제하세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다.
WarningLockedResolveAction: 백업했습니다. 이 기기를 해결됨으로 표시
WarningLockedResolveText: 이 기기가 '해결됨'으로 표시되어 있지 않아, 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. 보관함을 백업하고 로컬 데이터베이스를 재설정한 뒤 '이 기기를 해결됨으로 표시'를 선택해 주세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다.
WriteRedFlagAndRestart: 표시 후 재시작
Patches:
CompatibilityConflict: 호환성 (충돌 동작)
@@ -1840,3 +1836,7 @@ You are adding this device to an existing synchronisation setup.: 이 기기를
You can configure in the Obsidian Plugin Settings.: Obsidian 플러그인 설정에서 구성할 수 있습니다.
You should create a new synchronisation destination and rebuild your data there.: 새로운 동기화 대상을 만들고 그곳에서 데이터를 재구축해야 합니다.
You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.: 이 작업은 서버 데이터가 완전히 손상된 경우, 다른 모든 기기의 변경 사항이 더 이상 필요하지 않은 경우, 또는 데이터베이스 크기가 보관함 크기에 비해 비정상적으로 커진 경우처럼 예외적인 상황에서만 수행해야 합니다.
"I am ready, unlock the database": "준비되었습니다. 데이터베이스 잠금 해제"
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "의도치 않은 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. (이 기기는 '해결됨'으로 표시되어 있습니다.) 모든 기기가 '해결됨'으로 표시되면 데이터베이스 잠금을 해제하세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다."
"I have made a backup, mark this device as resolved": "백업했습니다. 이 기기를 해결됨으로 표시"
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "이 기기가 '해결됨'으로 표시되어 있지 않아, 보관함 손상을 막기 위해 원격 데이터베이스가 동기화 잠금 상태입니다. 보관함을 백업하고 로컬 데이터베이스를 재설정한 뒤 '이 기기를 해결됨으로 표시'를 선택해 주세요. 이 경고는 복제를 통해 기기가 해결되었음이 확인될 때까지 계속 표시됩니다."
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1383,10 +1383,6 @@ Ui:
Scram: 緊急處置
SendChunks: 傳送 chunks
Syncing: 同步中
WarningLockedReadyAction: 我已準備好,解鎖資料庫
WarningLockedReadyText: 為避免 Vault 發生非預期的損毀,遠端資料庫已鎖定同步。(此裝置已標記為「已處理」。)當你所有的裝置都標記為「已處理」後,即可解鎖資料庫。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。
WarningLockedResolveAction: 我已完成備份,將此裝置標記為已處理
WarningLockedResolveText: 由於此裝置尚未標記為「已處理」,為避免 Vault 損毀,遠端資料庫已鎖定同步。請先備份你的 Vault、重設本機資料庫,然後選擇「將此裝置標記為已處理」。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。
WriteRedFlagAndRestart: 標記後重新啟動
Patches:
CompatibilityConflict: 相容性(衝突行為)
@@ -1839,3 +1835,7 @@ Setup:
"Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": |-
垃圾回收完成。已刪除 chunks:${deletedChunks} / ${totalChunks}。耗時:${seconds} 秒。
"Failed to start replication after Garbage Collection.": 垃圾回收後無法啟動複寫。
"I am ready, unlock the database": "我已準備好,解鎖資料庫"
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "為避免 Vault 發生非預期的損毀,遠端資料庫已鎖定同步。(此裝置已標記為「已處理」。)當你所有的裝置都標記為「已處理」後,即可解鎖資料庫。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。"
"I have made a backup, mark this device as resolved": "我已完成備份,將此裝置標記為已處理"
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "由於此裝置尚未標記為「已處理」,為避免 Vault 損毀,遠端資料庫已鎖定同步。請先備份你的 Vault、重設本機資料庫,然後選擇「將此裝置標記為已處理」。在複寫程序確認此裝置已處理完畢之前,這則警告會持續顯示。"
+4 -4
View File
@@ -1373,10 +1373,6 @@ Ui:
Scram: 紧急处理
SendChunks: 发送数据块
Syncing: 同步
WarningLockedReadyAction: 我已准备好,立即解锁数据库
WarningLockedReadyText: 为防止意外的数据仓库损坏,远程数据库已被锁定,暂停同步。(此设备已被标记为“已确认”)当你的所有设备都标记为“已确认”后,再解锁数据库。在复制过程确认此设备已完成确认之前,此警告会持续显示。
WarningLockedResolveAction: 我已完成备份,将此设备标记为“已确认”
WarningLockedResolveText: 为防止数据仓库损坏,由于此设备尚未标记为“已确认”,远程数据库已被锁定,暂停同步。请先备份你的仓库、重置本地数据库,然后选择“将此设备标记为已确认”。在复制过程确认此设备已完成确认之前,此警告会持续显示。
WriteRedFlagAndRestart: 标记并重启
Patches:
CompatibilityConflict: 兼容性(冲突行为)
@@ -1599,3 +1595,7 @@ xxhash64 (Fastest): xxhash64(最快)
"Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": "通过日志文件进行同步。你需要事先部署好兼容 S3/MinIO/R2 的对象存储服务。"
"Peer-to-Peer only": "仅 Peer-to-Peer"
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "此功能可在设备之间直接同步,无需服务器;但同步时两台设备必须同时在线,且部分功能可能受限。互联网连接仅用于信令(发现对端),不用于数据传输。"
"I am ready, unlock the database": "我已准备好,立即解锁数据库"
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved.": "为防止意外的数据仓库损坏,远程数据库已被锁定,暂停同步。(此设备已被标记为“已确认”)当你的所有设备都标记为“已确认”后,再解锁数据库。在复制过程确认此设备已完成确认之前,此警告会持续显示。"
"I have made a backup, mark this device as resolved": "我已完成备份,将此设备标记为“已确认”"
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved.": "为防止数据仓库损坏,由于此设备尚未标记为“已确认”,远程数据库已被锁定,暂停同步。请先备份你的仓库、重置本地数据库,然后选择“将此设备标记为已确认”。在复制过程确认此设备已完成确认之前,此警告会持续显示。"
@@ -1,4 +1,5 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { usesEncryptedInternalMetadata } from "@vrtmrz/livesync-commonlib/replication";
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
@@ -42,7 +43,10 @@ function projectHeaders(value: string): readonly (readonly [name: string, value:
}
function projectRemoteSecurity(settings: RemoteDBSettings) {
return settings.encrypt
return [
settings.idDerivationVersion,
settings.idDerivationKey,
settings.encrypt
? ([
"encrypted",
settings.passphrase,
@@ -50,7 +54,8 @@ function projectRemoteSecurity(settings: RemoteDBSettings) {
settings.E2EEAlgorithm,
settings.permitEmptyPassphrase,
] as const)
: (["plain"] as const);
: (["plain"] as const),
] as const;
}
/**
@@ -77,6 +82,7 @@ export function getCouchDBReplicatorConfigurationIdentity(settings: RemoteDBSett
settings.useRequestAPI,
settings.disableRequestURI,
projectRemoteSecurity(settings),
usesEncryptedInternalMetadata(settings),
settings.enableCompression,
]);
}
@@ -31,6 +31,17 @@ describe("active Replicator configuration identity", () => {
});
}
it("replaces a connection when the independent ID key changes", () => {
const first = configuredSettings({ idDerivationVersion: 1, idDerivationKey: "a".repeat(64) });
const second = { ...first, idDerivationKey: "b".repeat(64) };
expect(getCouchDBReplicatorConfigurationIdentity(second)).not.toBe(
getCouchDBReplicatorConfigurationIdentity(first)
);
expect(getObjectStorageReplicatorConfigurationIdentity(second)).not.toBe(
getObjectStorageReplicatorConfigurationIdentity(first)
);
});
it.each([
["couchDB_URI", "https://other.example.test/base"],
["couchDB_DBNAME", "other-vault"],
@@ -67,6 +78,22 @@ describe("active Replicator configuration identity", () => {
);
});
it("recreates the CouchDB connection when internal Metadata encryption becomes effective", () => {
const active = configuredSettings({ usePathObfuscation: true, encryptInternalMetadata: false });
const enabled = { ...active, encryptInternalMetadata: true };
expect(getCouchDBReplicatorConfigurationIdentity(enabled)).not.toBe(
getCouchDBReplicatorConfigurationIdentity(active)
);
const inactive = { ...active, usePathObfuscation: false };
expect(getCouchDBReplicatorConfigurationIdentity({ ...inactive, encryptInternalMetadata: true })).toBe(
getCouchDBReplicatorConfigurationIdentity(inactive)
);
expect(getObjectStorageReplicatorConfigurationIdentity(enabled)).toBe(
getObjectStorageReplicatorConfigurationIdentity(active)
);
});
it("projects only the active CouchDB authentication mode", () => {
const basic = configuredSettings({ useJWT: false, jwtKey: "inactive-a" });
expect(getCouchDBReplicatorConfigurationIdentity({ ...basic, jwtKey: "inactive-b" })).toBe(
+2
View File
@@ -80,6 +80,8 @@ export async function generateReport(settings: ObsidianLiveSyncSettings, core: L
pluginConfig.couchDB_USER = REDACTED;
pluginConfig.passphrase = REDACTED;
pluginConfig.encryptedPassphrase = REDACTED;
pluginConfig.idDerivationKey = REDACTED;
pluginConfig.encryptedIdDerivationKey = REDACTED;
pluginConfig.encryptedCouchDBConnection = REDACTED;
pluginConfig.accessKey = REDACTED;
pluginConfig.secretKey = REDACTED;
+16
View File
@@ -10,6 +10,22 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
}));
describe("TURN credentials in diagnostic reports", () => {
it("redacts the derived ID key and its encrypted local wrapper", async () => {
const key = "f3205cc41d24116d8c2484993c9d9a2e667373af338ba02f2ee71199adb82f2e";
const wrapper = "encrypted-id-key-test-wrapper";
const settings = {
...DEFAULT_SETTINGS,
idDerivationVersion: 1 as const,
idDerivationKey: key,
encryptedIdDerivationKey: wrapper,
};
const core = { services: { vault: { isStorageInsensitive: () => false } } } as unknown as LiveSyncBaseCore;
const report = await generateReport(settings, core);
const text = JSON.stringify(report);
expect(text).not.toContain(key);
expect(text).not.toContain(wrapper);
});
it("redacts provider tokens in all profiles and runtime credentials", async () => {
const token = "private+token/with=symbols";
const provider = { P2P_managedType: "CF", P2P_managedId: "private-key", P2P_managedToken: token };
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { allMessages } from "./messages/combinedMessages.prod";
import { $msg, setLang } from "./translation";
function interpolationNames(message: string): string[] {
return [...message.matchAll(/\$\{([^}]+)\}/g)].map((match) => match[1]).sort();
}
describe("Russian catalogue interpolation", () => {
afterEach(() => setLang("def"));
it("retains the English interpolation values in each translated message", () => {
const mismatches = Object.entries(allMessages).flatMap(([key, messages]) => {
if (!messages.def || !messages.ru) return [];
const expected = interpolationNames(messages.def);
const actual = interpolationNames(messages.ru);
return JSON.stringify(actual) === JSON.stringify(expected) ? [] : [{ key, expected, actual }];
});
expect(mismatches).toEqual([]);
});
it("renders the generated QR image inside its display container", () => {
setLang("ru");
const qrImage = '<img src="data:image/png;base64,cXI=" alt="Fixture QR code">';
const message = $msg("Setup.QRCode", { qr_image: qrImage });
expect(message).toContain(`<div class="sls-qr">${qrImage}</div>`);
expect(message).not.toContain("${qr_image}");
});
it("shows both generated keys in their individual and combined copy areas", () => {
setLang("ru");
const publicKey = "PUBLIC-KEY-FIXTURE";
const privateKey = "PRIVATE-KEY-FIXTURE";
const message = $msg("Setting.GenerateKeyPair.Desc", { public_key: publicKey, private_key: privateKey });
expect(message.split(publicKey)).toHaveLength(3);
expect(message.split(privateKey)).toHaveLength(3);
expect(message).toContain(`${publicKey}\n${privateKey}`);
expect(message).not.toMatch(/\$\{(?:public_key|private_key)\}/);
});
it("keeps the measured and configured database sizes in the comparison table", () => {
setLang("ru");
const message = $msg("moduleCheckRemoteSize.msgDatabaseGrowing", {
estimatedSize: "123 MB",
maxSize: "456 MB",
});
expect(message).toContain("| 123 MB | 456 MB |");
expect(message).not.toMatch(/\$\{(?:estimatedSize|maxSize)\}/);
});
});
@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
// The readiness and suspension helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
vi.mock("@/modules/features/ModuleLog.ts", () => ({
MARK_DONE: "",
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { createServiceContext, type ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
import { ConfigSync } from "./CmdConfigSync";
type HarnessOptions = {
ready?: boolean;
suspended?: boolean;
};
const PERIODIC_SWEEP_INTERVAL = 60 * 1000;
class TestAppLifecycleService extends InjectableAppLifecycleService {}
class TestSettingService extends InjectableSettingService<ServiceContext> {}
/**
* Binds ConfigSync to Commonlib's real ControlService, so settings are applied in the order the plug-in uses.
*/
function setup(options: HarnessOptions = {}) {
const context = createServiceContext();
const settings = {
isConfigured: true,
usePluginSync: true,
autoSweepPlugins: true,
autoSweepPluginsPeriodic: true,
watchInternalFileChanges: false,
pluginSyncExtendedSetting: {},
};
const API = {
addLog: vi.fn(),
getSystemConfigDir: () => ".obsidian",
};
const appLifecycle = new TestAppLifecycleService(context, {
settingService: { currentSettings: () => settings },
} as never);
const setting = new TestSettingService(context, { APIService: API } as never);
const control = new ControlService(context, {
APIService: API,
appLifecycleService: appLifecycle,
databaseService: { localDatabase: { refreshSettings: vi.fn() } },
fileProcessingService: { commitPendingFileEvents: vi.fn(async () => true) },
settingService: setting,
} as never);
const handler = () => ({ addHandler: vi.fn() });
const services = {
API,
appLifecycle,
conflict: { getOptionalConflictCheckMethod: handler() },
control,
databaseEvents: { onDatabaseInitialised: handler() },
fileProcessing: { processOptionalFileEvent: handler() },
replication: { onBeforeReplicate: handler(), processVirtualDocument: handler() },
setting,
};
const periodicPluginSweepProcessor = { disable: vi.fn(), enable: vi.fn() };
const scanAllConfigFiles = vi.fn(async () => undefined);
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: { settings, services },
periodicPluginSweepProcessor,
scanAllConfigFiles,
});
configSync.onBindFunction(configSync.core, services as never);
if (options.ready ?? true) appLifecycle.markIsReady();
appLifecycle.setSuspended(options.suspended ?? false);
// The periodic sweep is running when the last call left it enabled with a non-zero interval.
const isPeriodicSweepRunning = () => {
const calls = [
...periodicPluginSweepProcessor.disable.mock.invocationCallOrder.map((order) => ({ order, interval: 0 })),
...periodicPluginSweepProcessor.enable.mock.calls.map(([interval], index) => ({
order: periodicPluginSweepProcessor.enable.mock.invocationCallOrder[index],
interval: interval as number,
})),
].sort((a, b) => a.order - b.order);
return (calls[calls.length - 1]?.interval ?? 0) > 0;
};
return { appLifecycle, control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles };
}
describe("ConfigSync when settings are applied", () => {
beforeEach(() => {
vi.stubGlobal("activeDocument", { querySelector: vi.fn(() => null) });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([true, false])(
"does not sweep configuration files while the plug-in is suspended (ready: %s)",
async (ready) => {
const { control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles } = setup({
ready,
suspended: true,
});
await control.applySettings();
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalled();
expect(scanAllConfigFiles).not.toHaveBeenCalled();
expect(periodicPluginSweepProcessor.enable).not.toHaveBeenCalled();
expect(isPeriodicSweepRunning()).toBe(false);
}
);
it("sweeps configuration files once and starts the periodic sweep while the plug-in is running", async () => {
const { control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles } = setup();
await control.applySettings();
expect(scanAllConfigFiles).toHaveBeenCalledOnce();
expect(periodicPluginSweepProcessor.enable).toHaveBeenCalledOnce();
expect(periodicPluginSweepProcessor.enable).toHaveBeenCalledWith(PERIODIC_SWEEP_INTERVAL);
expect(isPeriodicSweepRunning()).toBe(true);
});
it("stops the periodic sweep when all synchronisation is suspended and restarts it once on resumption", async () => {
const { appLifecycle, control, isPeriodicSweepRunning, periodicPluginSweepProcessor, scanAllConfigFiles } =
setup();
await control.applySettings();
scanAllConfigFiles.mockClear();
periodicPluginSweepProcessor.enable.mockClear();
appLifecycle.setSuspended(true);
await control.applySettings();
expect(scanAllConfigFiles).not.toHaveBeenCalled();
expect(periodicPluginSweepProcessor.enable).not.toHaveBeenCalled();
expect(isPeriodicSweepRunning()).toBe(false);
appLifecycle.setSuspended(false);
await control.applySettings();
expect(scanAllConfigFiles).toHaveBeenCalledOnce();
expect(periodicPluginSweepProcessor.enable).toHaveBeenCalledOnce();
expect(isPeriodicSweepRunning()).toBe(true);
});
});
@@ -52,7 +52,7 @@ vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
}));
import { cancelTask } from "@/common/utils.ts";
import { ConfigSync } from "./CmdConfigSync";
import { ConfigSync, PluginDataExDisplayV2, type IPluginDataExDisplay } from "./CmdConfigSync";
describe("ConfigSync commands", () => {
it("shows the Customisation Sync command only whilst the feature is enabled", () => {
@@ -111,3 +111,25 @@ describe("ConfigSync commands", () => {
expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce();
});
});
describe("PluginDataExDisplayV2", () => {
const entryWithModifiedTimes = (mtimes: number[]) =>
new PluginDataExDisplayV2({
documentPath: "ix:device/plugin_main/example",
category: "PLUGIN_MAIN",
name: "example",
term: "device",
files: mtimes.map((mtime, index) => ({ filename: `file-${index}`, mtime, data: [] })),
} as unknown as IPluginDataExDisplay);
it("averages millisecond modification times without truncating them to 32 bits", () => {
const mtime = Date.UTC(2026, 8, 15);
expect(entryWithModifiedTimes([mtime, mtime, mtime]).mtime).toBe(mtime);
});
it("keeps a newer copy newer than an older one", () => {
const newer = entryWithModifiedTimes([Date.UTC(2026, 8, 15)]);
const older = entryWithModifiedTimes([Date.UTC(2026, 8, 2)]);
expect(newer.mtime).toBeGreaterThan(older.mtime);
});
});
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({
addIcon: vi.fn(),
diff_match_patch: class DiffMatchPatch {},
normalizePath: vi.fn((path: string) => path),
parseYaml: vi.fn(),
Platform: {},
}));
vi.mock("./PluginDialogModal.ts", () => ({
PluginDialogModal: class PluginDialogModal {},
}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
ConflictResolveModal: class ConflictResolveModal {},
}));
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
vi.mock("@/modules/features/ModuleLog.ts", () => ({
MARK_DONE: "",
}));
vi.mock("@/common/types.ts", () => ({
ICXHeader: "ix:",
PERIODIC_PLUGIN_SWEEP: 60,
}));
vi.mock("@/common/utils.ts", () => ({
cancelTask: vi.fn(),
EVEN: Symbol("even"),
isCustomisationSyncMetadata: vi.fn(),
isPluginMetadata: vi.fn(),
scheduleTask: vi.fn(),
}));
vi.mock("@/common/PeriodicProcessor.ts", () => ({
PeriodicProcessor: class PeriodicProcessor {},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
eventHub: {
onEvent: vi.fn(),
},
}));
vi.mock("@/common/translation", () => ({
$msg: vi.fn((message: string) => message),
}));
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
getObsidianCommunityPluginManager: vi.fn(),
}));
import { scheduleTask } from "@/common/utils.ts";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConfigSync } from "./CmdConfigSync";
type LifecycleState = {
ready: boolean;
suspended: boolean;
};
const CONFIG_FILE = ".obsidian/snippets/theme.css" as FilePath;
function setup(state: LifecycleState) {
const appLifecycle = {
isReady: vi.fn(() => state.ready),
isSuspended: vi.fn(() => state.suspended),
};
const statHidden = vi.fn(async () => ({ type: "file", ctime: 0, mtime: 1_000, size: 1 }));
const storeCustomizationFiles = vi.fn(async () => undefined);
// Obsidian adds Array.prototype.contains, which is not available under Node.
const recentProcessedInternalFiles = Object.assign([] as string[], {
contains: (key: string) => recentProcessedInternalFiles.includes(key),
});
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
Object.assign(configSync, {
core: {
settings: {
usePluginSync: true,
pluginSyncExtendedSetting: {},
},
services: {
appLifecycle,
API: { getSystemConfigDir: () => ".obsidian" },
},
storageAccess: { statHidden },
},
recentProcessedInternalFiles,
storeCustomizationFiles,
filenameToUnifiedKey: (path: string) => `ix:${path}`,
});
return {
appLifecycle,
configSync,
statHidden,
};
}
describe("ConfigSync readiness", () => {
beforeEach(() => {
vi.mocked(scheduleTask).mockClear();
});
it("leaves a configuration file event unhandled before the plug-in is ready", async () => {
const { appLifecycle, configSync, statHidden } = setup({ ready: false, suspended: false });
const handled = await configSync.watchVaultRawEventsAsync(CONFIG_FILE);
expect(scheduleTask).not.toHaveBeenCalled();
expect(statHidden).not.toHaveBeenCalled();
expect(handled).toBe(false);
expect(appLifecycle.isReady).toHaveBeenCalled();
});
it("schedules a configuration file event for storing once the plug-in is ready", async () => {
const { configSync, statHidden } = setup({ ready: true, suspended: false });
await expect(configSync.watchVaultRawEventsAsync(CONFIG_FILE)).resolves.toBe(true);
expect(statHidden).toHaveBeenCalledWith(CONFIG_FILE);
expect(scheduleTask).toHaveBeenCalledOnce();
expect(scheduleTask).toHaveBeenCalledWith(`ix:${CONFIG_FILE}`, 100, expect.any(Function));
});
});
+7 -15
View File
@@ -368,7 +368,8 @@ export class PluginDataExDisplayV2 {
return this._version;
}
get mtime(): number {
return ~~this.files.reduce((a, b) => a + b.mtime, 0) / this.files.length;
// Millisecond timestamps exceed 32 bits, so `~~` would wrap them.
return Math.floor(this.files.reduce((a, b) => a + b.mtime, 0) / this.files.length);
}
}
export type PluginDataEx = {
@@ -1197,20 +1198,11 @@ export class ConfigSync extends LiveSyncCommands {
}
return true;
}
async _everyRealizeSettingSyncMode(): Promise<boolean> {
_everyRealizeSettingSyncMode(): Promise<boolean> {
// applySettings calls onResuming after this handler when synchronisation is active.
// That handler scans and restarts the sweep; doing so here would scan twice.
this.periodicPluginSweepProcessor?.disable();
if (!this._isMainReady) return true;
if (!this._isMainSuspended()) return true;
if (!this.isThisModuleEnabled()) return true;
if (this.settings.autoSweepPlugins) {
await this.scanAllConfigFiles(false);
}
this.periodicPluginSweepProcessor.enable(
this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges
? PERIODIC_PLUGIN_SWEEP * 1000
: 0
);
return true;
return Promise.resolve(true);
}
recentProcessedInternalFiles = [] as string[];
@@ -1487,7 +1479,7 @@ export class ConfigSync extends LiveSyncCommands {
}
async watchVaultRawEventsAsync(path: FilePath) {
if (!this._isMainReady) return false;
if (!this._isMainReady()) return false;
if (this._isMainSuspended()) return false;
if (!this.isThisModuleEnabled()) return false;
// if (!this.isTargetPath(path)) return false;
+14 -17
View File
@@ -12,6 +12,7 @@
// import { askString } from "../../common/utils";
import { Menu } from "@/deps.ts";
import { $msg as translateMessage } from "@/common/translation";
import { selectSourceTerms } from "./PluginTerms.ts";
export let list: IPluginDataExDisplay[] = [];
export let thisTerm = "";
@@ -182,24 +183,20 @@
}
}
async function updateTerms(list: IPluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) {
async function updateTerms(
list: IPluginDataExDisplay[],
selectNewest: boolean,
isMaintenanceMode: boolean,
hideNotApplicable: boolean
) {
const local = list.find((e) => e.term == thisTerm);
// selected = "";
if (isMaintenanceMode) {
terms = [...new Set(list.map((e) => e.term))];
} else if (hideNotApplicable) {
const termsTmp = [];
const wk = [...new Set(list.map((e) => e.term))];
for (const termName of wk) {
const remote = list.find((e) => e.term == termName);
if ((await comparePlugin(local, remote)).canApply) {
termsTmp.push(termName);
}
}
terms = [...termsTmp];
} else {
terms = [...new Set(list.map((e) => e.term))].filter((e) => e != thisTerm);
}
terms = await selectSourceTerms(
list,
thisTerm,
{ isMaintenanceMode, hideNotApplicable },
async (local, remote) => (await comparePlugin(local, remote)).canApply
);
let newest: IPluginDataExDisplay | undefined = local;
if (selectNewest) {
for (const term of terms) {
@@ -230,7 +227,7 @@
}
// currentSelectNewest = selectNewest;
}
updateTerms(list, doSelectNewest, isMaintenanceMode);
updateTerms(list, doSelectNewest, isMaintenanceMode, hideNotApplicable);
currentSelectNewest = selectNewest;
}
$: {
+2 -1
View File
@@ -32,7 +32,8 @@
export let core :LiveSyncBaseCore;
// $: core = plugin.core;
$: hideNotApplicable = false;
// Comparing every copy is only worth it while the user asks to hide the unchanged ones.
$: hideNotApplicable = hideEven;
$: thisTerm = core.services.setting.getDeviceAndVaultName();
const addOn = core.getAddOn<ConfigSync>(ConfigSync.name)!;
+32
View File
@@ -0,0 +1,32 @@
import type { IPluginDataExDisplay } from "./CmdConfigSync.ts";
export type CanApplyFrom = (
local: IPluginDataExDisplay | undefined,
remote: IPluginDataExDisplay | undefined
) => Promise<boolean>;
/**
* Devices offered as sources for one item in the Customisation Sync dialogue.
*
* Maintenance mode lists every device, including this one. Otherwise every other
* device that has the item is listed; with `hideNotApplicable`, only devices whose
* copy can actually be applied remain, so an item that is the same everywhere ends
* up with no source and is shown as "All the same or non-existent".
*/
export async function selectSourceTerms(
list: IPluginDataExDisplay[],
thisTerm: string,
options: { isMaintenanceMode: boolean; hideNotApplicable: boolean },
canApplyFrom: CanApplyFrom
): Promise<string[]> {
const terms = [...new Set(list.map((e) => e.term))];
if (options.isMaintenanceMode) return terms;
if (!options.hideNotApplicable) return terms.filter((term) => term != thisTerm);
const local = list.find((e) => e.term == thisTerm);
const applicable: string[] = [];
for (const term of terms) {
const remote = list.find((e) => e.term == term);
if (await canApplyFrom(local, remote)) applicable.push(term);
}
return applicable;
}
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import type { IPluginDataExDisplay } from "./CmdConfigSync.ts";
import { selectSourceTerms } from "./PluginTerms.ts";
const copyOn = (term: string) => ({ term, files: [] }) as unknown as IPluginDataExDisplay;
const list = [copyOn("desktop"), copyOn("phone"), copyOn("tablet")];
const differsOn =
(...terms: string[]) =>
(_local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) =>
Promise.resolve(terms.includes(remote?.term ?? ""));
describe("selectSourceTerms", () => {
it("offers every other device without comparing copies by default", async () => {
const canApplyFrom = vi.fn(differsOn());
const terms = await selectSourceTerms(
list,
"desktop",
{ isMaintenanceMode: false, hideNotApplicable: false },
canApplyFrom
);
expect(terms).toEqual(["phone", "tablet"]);
expect(canApplyFrom).not.toHaveBeenCalled();
});
it("offers every device, including this one, in maintenance mode", async () => {
const terms = await selectSourceTerms(
list,
"desktop",
{ isMaintenanceMode: true, hideNotApplicable: true },
differsOn()
);
expect(terms).toEqual(["desktop", "phone", "tablet"]);
});
it("leaves out devices whose copy is the same when hiding items that are not applicable", async () => {
const terms = await selectSourceTerms(
list,
"desktop",
{ isMaintenanceMode: false, hideNotApplicable: true },
differsOn("tablet")
);
expect(terms).toEqual(["tablet"]);
});
it("offers no source for an item that is the same on every device", async () => {
const terms = await selectSourceTerms(
list,
"desktop",
{ isMaintenanceMode: false, hideNotApplicable: true },
differsOn()
);
expect(terms).toEqual([]);
});
});
@@ -1,9 +1,6 @@
import type { ObsidianLiveSyncSettings, SettingsMigrationState } from "@vrtmrz/livesync-commonlib/settings";
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
import type {
ReviewHarnessScenarioResult,
ReviewHarnessScenarioStatus,
} from "./reviewHarnessTypes";
import type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
export type { ReviewHarnessScenarioResult, ReviewHarnessScenarioStatus } from "./reviewHarnessTypes";
@@ -32,6 +29,14 @@ export const REVIEW_HARNESS_SCENARIOS = [
mode: "automatic",
access: "dedicated-vault-fixtures",
},
{
id: "id-generation-performance",
title: "ID generation performance",
description:
"Measures legacy and independent IDs with fixed in-memory inputs. Reports time per 1,000 IDs and per ID, key derivation time, and JavaScript heap samples where available. Keep Obsidian in the foreground.",
mode: "automatic",
access: "read-only",
},
] as const;
export const REVIEW_HARNESS_SCENARIO_IDS = REVIEW_HARNESS_SCENARIOS.map(({ id }) => id);
@@ -114,7 +119,9 @@ const NEW_VAULT_RECOMMENDATION_KEYS = [
"E2EEAlgorithm",
] as const;
type LifecycleSettingKey = (typeof PRESERVED_SYNC_SETTING_KEYS)[number] | (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
type LifecycleSettingKey =
| (typeof PRESERVED_SYNC_SETTING_KEYS)[number]
| (typeof NEW_VAULT_RECOMMENDATION_KEYS)[number];
type SettingsForLifecycleInspection = Partial<Pick<ObsidianLiveSyncSettings, LifecycleSettingKey>>;
export function inspectSettingsLifecycle(input: {
@@ -130,9 +137,7 @@ export function inspectSettingsLifecycle(input: {
};
}
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter(
(key) => typeof input.settings[key] !== "boolean"
);
const invalidSyncSettings = PRESERVED_SYNC_SETTING_KEYS.filter((key) => typeof input.settings[key] !== "boolean");
if (invalidSyncSettings.length > 0) {
return {
status: "failed",
@@ -205,6 +210,7 @@ export interface ReviewHarnessReportScenario {
readonly mode: ReviewHarnessScenarioMode;
readonly status: ReviewHarnessScenarioStatus;
readonly detail: string;
readonly observations?: readonly string[];
}
export interface ReviewHarnessReportInput {
@@ -248,13 +254,15 @@ export function formatReviewHarnessReport(input: ReviewHarnessReportInput): stri
);
const scenarios = table(
["Scenario", "Mode", "Status", "Detail"],
input.scenarios.map(({ id, title, mode, status, detail }) => [
`${title} (${id})`,
mode,
status,
detail,
])
input.scenarios.map(({ id, title, mode, status, detail }) => [`${title} (${id})`, mode, status, detail])
);
const observations = input.scenarios
.filter((scenario) => scenario.observations?.length)
.map(
({ title, observations }) =>
`### ${title}\n\n${observations!.map((value) => `- ${tableCell(value)}`).join("\n")}`
)
.join("\n\n");
return `## Self-hosted LiveSync Review Harness report
Generated at \`${tableCell(input.generatedAt)}\`.
@@ -267,6 +275,8 @@ ${environment}
${scenarios}
${observations}
<details>
<summary>Event transcript</summary>
@@ -75,6 +75,7 @@ describe("Review Harness contract", () => {
"settings-lifecycle",
"compatibility-review",
"vault-round-trip",
"id-generation-performance",
]);
});
@@ -21,6 +21,7 @@ export interface ReviewHarnessRuntime {
getCompatibilityPause(): CompatibilityPause | undefined;
openCompatibilityReview(): Promise<void>;
runVaultRoundTrip(): Promise<ReviewHarnessScenarioResult>;
runIdBenchmark(): Promise<ReviewHarnessScenarioResult>;
readContinuation(): string | null;
writeContinuation(value: string): void;
deleteContinuation(): void;
@@ -159,6 +160,8 @@ export class ReviewHarnessController {
});
} else if (id === "vault-round-trip") {
result = await this.runtime.runVaultRoundTrip();
} else if (id === "id-generation-performance") {
result = await this.runtime.runIdBenchmark();
} else {
const inspection = this.inspectCompatibilityReview();
result =
@@ -206,10 +209,7 @@ export class ReviewHarnessController {
detail: "The device-local compatibility review remains pending.",
observations: inspection.observations,
};
this.record(
"compatibility-review-updated",
this.results["compatibility-review"].status
);
this.record("compatibility-review-updated", this.results["compatibility-review"].status);
} catch (error) {
this.setUnexpectedFailure("compatibility-review", error);
} finally {
@@ -259,6 +259,7 @@ export class ReviewHarnessController {
mode,
status: this.results[id].status,
detail: this.results[id].detail,
observations: this.results[id].observations,
})),
transcript: this.transcript,
});
@@ -80,6 +80,11 @@ function createRuntime(): ReviewHarnessRuntime & {
detail: "The owned fixture tree was exercised and removed.",
observations: [],
})),
runIdBenchmark: vi.fn(async () => ({
status: "passed" as const,
detail: "ID generation measurements completed.",
observations: ["Chunk 256 B: 1000 IDs total=43.00 ms; per ID=0.0430 ms"],
})),
readContinuation() {
return this.continuation;
},
@@ -150,6 +155,60 @@ describe("ReviewHarnessController", () => {
expect(runtime.reportError).toHaveBeenCalledOnce();
});
it("runs ID measurements on request and includes their units in the copied report", async () => {
const runtime = createRuntime();
const controller = new ReviewHarnessController(runtime);
await controller.runAutomaticScenarios();
expect(runtime.runIdBenchmark).not.toHaveBeenCalled();
await controller.runScenario("id-generation-performance");
await controller.copyReport();
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
expect(controller.snapshot().results["id-generation-performance"].status).toBe("passed");
expect(vi.mocked(runtime.copyText).mock.calls[0][0]).toContain("1000 IDs total=43.00 ms; per ID=0.0430 ms");
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
expect(runtime.events).toEqual([]);
expect(runtime.continuation).toBeNull();
});
it("excludes an unexpected measurement error from the copied report", async () => {
const runtime = createRuntime();
runtime.runIdBenchmark = vi.fn().mockRejectedValue(new Error("private measurement error"));
const controller = new ReviewHarnessController(runtime);
await controller.runScenario("id-generation-performance");
expect(controller.snapshot().results["id-generation-performance"].status).toBe("failed");
expect(controller.createReport()).not.toContain("private measurement error");
expect(runtime.reportError).toHaveBeenCalledOnce();
});
it("does not overlap an ID measurement with another scenario", async () => {
const runtime = createRuntime();
let finish!: () => void;
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
runtime.runIdBenchmark = vi.fn(async () => {
await pending;
return { status: "passed" as const, detail: "Measured", observations: [] };
});
const controller = new ReviewHarnessController(runtime);
const running = controller.runScenario("id-generation-performance");
await controller.runScenario("id-generation-performance");
await controller.runScenario("vault-round-trip");
expect(runtime.runIdBenchmark).toHaveBeenCalledOnce();
expect(runtime.runVaultRoundTrip).not.toHaveBeenCalled();
expect(controller.snapshot().running).toBe(true);
finish();
await running;
expect(controller.snapshot().running).toBe(false);
});
it("deletes a one-shot continuation before exposing the resumed guided step", () => {
const runtime = createRuntime();
runtime.continuation = JSON.stringify({
@@ -167,9 +226,7 @@ describe("ReviewHarnessController", () => {
expect(controller.snapshot().results["compatibility-review"]).toMatchObject({
status: "waiting-for-user",
});
expect(controller.snapshot().resumedRequestId).toBe(
"compatibility-review-2026-07-18T11:59:00.000Z"
);
expect(controller.snapshot().resumedRequestId).toBe("compatibility-review-2026-07-18T11:59:00.000Z");
});
it("does not copy rejected continuation values into the report", () => {
@@ -0,0 +1,109 @@
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
export interface IdBenchmarkOperations {
deriveKey(): Promise<unknown>;
chunkId(piece: string, independent: boolean): Promise<string>;
documentId(path: string, independent: boolean): Promise<string>;
}
type BenchmarkPerformance = Pick<Performance, "now"> & {
readonly memory?: { readonly usedJSHeapSize: number };
};
const ID_COUNT = 1000;
const SAMPLES = 3;
const BATCH_SIZE = 100;
const WARMUP_COUNT = 32;
function readHeap(clock: BenchmarkPerformance): number | undefined {
try {
const bytes = clock.memory?.usedJSHeapSize;
return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
} catch {
return undefined;
}
}
function summary(samples: readonly number[]): string {
const sorted = [...samples].sort((a, b) => a - b);
return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`;
}
export async function runReviewHarnessIdBenchmark(
operations: IdBenchmarkOperations,
clock: BenchmarkPerformance = performance,
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
): Promise<ReviewHarnessScenarioResult> {
const before = readHeap(clock);
let highest = before;
const sampleHeap = () => {
const value = readHeap(clock);
if (value !== undefined) highest = Math.max(highest ?? value, value);
return value;
};
const observations = [
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
"Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.",
];
const derivationSamples: number[] = [];
for (let sample = 0; sample < SAMPLES; sample++) {
await yieldControl();
const started = clock.now();
await operations.deriveKey();
derivationSamples.push(clock.now() - started);
sampleHeap();
}
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
const cases = [
...[256, 4096, 32768].map((bytes) => {
const prefix = "r".repeat(bytes - 8);
return {
label: `Chunk IDs, ${bytes} B`,
run: (i: number, independent: boolean) =>
operations.chunkId(prefix + i.toString(36).padStart(8, "0"), independent),
};
}),
{
label: "Obfuscated document IDs",
run: (i: number, independent: boolean) => operations.documentId(`benchmark/path-${i}.md`, independent),
},
];
for (const scenario of cases) {
const samples: [number[], number[]] = [[], []];
for (let sample = 0; sample < SAMPLES; sample++) {
for (const independent of sample % 2 === 0 ? [false, true] : [true, false]) {
for (let i = 0; i < WARMUP_COUNT; i++) await scenario.run(i, independent);
let elapsed = 0;
for (let batch = 0; batch < ID_COUNT; batch += BATCH_SIZE) {
await yieldControl();
const started = clock.now();
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
elapsed += clock.now() - started;
sampleHeap();
}
samples[independent ? 1 : 0].push(elapsed);
}
}
for (const [index, values] of samples.entries()) {
const median = [...values].sort((a, b) => a - b)[1];
observations.push(
`${scenario.label}, ${index === 0 ? "legacy" : "independent"}: ${ID_COUNT} IDs total ${summary(values)}; per ID=${(median / ID_COUNT).toFixed(4)} ms.`
);
}
}
const after = sampleHeap();
if (highest === undefined) {
observations.push("JavaScript heap: unavailable on this device.");
} else {
const mib = (bytes: number | undefined) =>
bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`;
observations.push(
`JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`
);
}
observations.push(
"Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak."
);
return { status: "passed", detail: "ID generation measurements completed.", observations };
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { runReviewHarnessIdBenchmark, type IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
function fixture() {
let elapsed = 0;
let derivations = 0;
const chunkCounts = [0, 0];
const documentCounts = [0, 0];
const chunkSizes = new Set<number>();
const operations: IdBenchmarkOperations = {
deriveKey: () => {
derivations++;
elapsed += 42;
return Promise.resolve("private-derived-key");
},
chunkId: (piece, independent) => {
chunkCounts[independent ? 1 : 0]++;
chunkSizes.add(piece.length);
elapsed += independent ? 2 : 1;
return Promise.resolve("private-chunk-id");
},
documentId: (_path, independent) => {
documentCounts[independent ? 1 : 0]++;
elapsed += independent ? 4 : 3;
return Promise.resolve("private-document-id");
},
};
return {
operations,
now: () => elapsed,
yieldControl: () => {
elapsed += 100;
return Promise.resolve();
},
counts: () => ({ derivations, chunkCounts, documentCounts, chunkSizes: [...chunkSizes] }),
};
}
describe("Review Harness ID measurements", () => {
it("reports totals and per-ID timings separately, excluding warm-up and cooperative pauses", async () => {
const f = fixture();
const result = await runReviewHarnessIdBenchmark(f.operations, { now: f.now }, f.yieldControl);
const report = result.observations.join("\n");
expect(result.status).toBe("passed");
expect(report).toContain("1000 IDs total median=1000.00 ms; range=1000.00–1000.00 ms; per ID=1.0000 ms");
expect(report).toContain("1000 IDs total median=2000.00 ms; range=2000.00–2000.00 ms; per ID=2.0000 ms");
expect(report).toContain("Obfuscated document IDs, legacy: 1000 IDs total median=3000.00 ms");
expect(report).toContain("Obfuscated document IDs, independent: 1000 IDs total median=4000.00 ms");
expect(report).toContain("ID key derivation at save time: median=42.00 ms");
expect(report).toContain("JavaScript heap: unavailable on this device.");
expect(report).not.toContain("private-");
expect(f.counts()).toEqual({
derivations: 3,
chunkCounts: [9288, 9288],
documentCounts: [3096, 3096],
chunkSizes: [256, 4096, 32768],
});
});
it("labels the highest sampled heap separately from total app RAM and allows a lower final sample", async () => {
const f = fixture();
let reads = 0;
const clock = {
now: f.now,
get memory() {
return { usedJSHeapSize: (reads++ === 0 ? 2 : reads === 2 ? 5 : 1) * 1048576 };
},
};
const result = await runReviewHarnessIdBenchmark(f.operations, clock, f.yieldControl);
expect(result.observations).toContain(
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
);
expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak");
});
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
"keeps timings usable when the heap API returns %s",
async (value) => {
const f = fixture();
const result = await runReviewHarnessIdBenchmark(
f.operations,
{
now: f.now,
get memory() {
if (value === "throws") throw new Error("Heap API unavailable");
return { usedJSHeapSize: value as number };
},
},
f.yieldControl
);
expect(result.status).toBe("passed");
expect(result.observations).toContain("JavaScript heap: unavailable on this device.");
expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity|private-/u);
}
);
});
@@ -0,0 +1,35 @@
import { DEFAULT_SETTINGS, deriveIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { path2id_base } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { HashManager } from "@vrtmrz/livesync-commonlib/hashing";
import type { IdBenchmarkOperations } from "./reviewHarnessIdBenchmark";
const FIXTURE_PASSPHRASE = "Self-hosted LiveSync ID benchmark passphrase";
const FIXTURE_SOURCE = "Self-hosted LiveSync ID benchmark source";
const FIXTURE_KEY = "ab".repeat(32);
export async function createIdBenchmarkOperations(): Promise<IdBenchmarkOperations> {
const managers: HashManager[] = [];
for (const independent of [false, true]) {
const settings = Object.freeze({
...DEFAULT_SETTINGS,
encrypt: true,
passphrase: FIXTURE_PASSPHRASE,
hashAlg: "xxhash64" as const,
idDerivationVersion: independent ? (1 as const) : (0 as const),
idDerivationKey: independent ? FIXTURE_KEY : "",
});
// HashManager only reads currentSettings; this fixture has no storage or live service access.
const settingService = { currentSettings: () => settings } as HashManager["options"]["settingService"];
const manager = new HashManager({ settingService });
if (!(await manager.initialise())) throw new Error("The benchmark hash manager could not initialise.");
managers.push(manager);
}
return {
deriveKey: () => deriveIdKey(FIXTURE_SOURCE),
chunkId: (piece, independent) => managers[independent ? 1 : 0].computeHash(piece),
// Fixture paths are already normalised; use the same ID calculation as PathService.
documentId: (path, independent) =>
path2id_base(path as FilePath, FIXTURE_PASSPHRASE, false, independent ? FIXTURE_KEY : undefined),
};
}
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { createIdBenchmarkOperations } from "./reviewHarnessIdBenchmarkRuntime";
describe("Review Harness benchmark implementation", () => {
it("uses the packaged legacy and independent algorithms with isolated fixed settings", async () => {
const originalDefaults = structuredClone(DEFAULT_SETTINGS);
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network access is forbidden"));
try {
const operations = await createIdBenchmarkOperations();
const chunk = "r".repeat(256);
const legacy = await operations.chunkId(chunk, false);
const independent = await operations.chunkId(chunk, true);
expect(legacy).toMatch(/^\+[0-9a-z]{1,13}$/u);
expect(independent).toMatch(/^\+[0-9a-f]{64}$/u);
expect(independent).toBe("+9223e53d99e80c29effee9e95e38ed168d13c14f717054f9e996a1cd0a597000");
expect(await operations.chunkId(chunk, false)).toBe(legacy);
expect(await operations.chunkId(chunk, true)).toBe(independent);
expect(await operations.chunkId("s".repeat(256), true)).not.toBe(independent);
const legacyPath = await operations.documentId("benchmark/path-1.md", false);
const independentPath = await operations.documentId("benchmark/path-1.md", true);
expect(legacyPath).toMatch(/^f:[0-9a-f]{64}$/u);
expect(independentPath).toMatch(/^f:[0-9a-f]{64}$/u);
expect(legacyPath).not.toBe(independentPath);
expect(await operations.documentId("benchmark/path-1.md", true)).toBe(independentPath);
const second = await createIdBenchmarkOperations();
expect(await second.chunkId(chunk, true)).toBe(independent);
expect(await operations.deriveKey()).toMatch(/^[0-9a-f]{64}$/u);
expect(fetch).not.toHaveBeenCalled();
expect(DEFAULT_SETTINGS).toEqual(originalDefaults);
} finally {
fetch.mockRestore();
}
});
});
@@ -99,6 +99,17 @@ function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private requiresIdConfigurationReview(assessment: TweakAssessment): boolean {
if (!assessment.entries.some(({ key, relation }) => key === "idDerivationVersion" && relation === "different")) {
return false;
}
Logger(
"The document ID configurations differ. Import the correct Setup URI, or configure the matching ID key, before synchronising.",
LOG_LEVEL_NOTICE
);
return true;
}
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
const currentModified = current.tweakModified;
@@ -196,6 +207,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
assessment = assessTweakCompatibility(this.settings, preferred)
): Promise<[TweakValues | boolean, boolean]> {
if (assessment.alignment === "matched") return [false, false];
if (this.requiresIdConfigurationReview(assessment)) return [false, false];
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
@@ -363,6 +375,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
const trialSignature = JSON.stringify(trialSetting);
const currentSignature = resolutionSettingsSignature(this.settings);
const assessment = assessTweakCompatibility(trialSetting, preferred);
if (this.requiresIdConfigurationReview(assessment)) return { result: false, requireFetch: false };
if (assessment.alignment === "matched") {
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
return { result: false, requireFetch: false };
@@ -7,7 +7,7 @@ import {
type TweakValues,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { extractObject } from "octagonal-wheels/object";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { assessTweakCompatibility, configuredIdKey } from "@vrtmrz/livesync-commonlib/settings";
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
import { setLang } from "@/common/translation";
import {
@@ -74,6 +74,68 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
}
describe("ModuleResolvingMismatchedTweaks", () => {
it.each([0, 1] as const)(
"keeps ID configuration %s when automatically aligning Chunk settings",
async (idDerivationVersion) => {
const idDerivationKey = idDerivationVersion === 1 ? "ab".repeat(32) : "";
const { module, core, askSelectStringDialogue } = createModule({
encrypt: true,
usePathObfuscation: false,
idDerivationVersion,
idDerivationKey,
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: 1,
});
const preferred: TweakValues = {
...extractObject(TweakValuesTemplate, core.settings),
idDerivationVersion: idDerivationVersion === 1 ? 0 : 1,
hashAlg: "xxhash32",
tweakModified: 2,
};
core._services.tweakValue = {
checkAndAskResolvingMismatched: module._checkAndAskResolvingMismatchedTweaks.bind(module),
};
core._services.setting.saveSettingData.mockImplementation(async () => {
configuredIdKey(core.settings);
});
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).resolves.toBe("CHECKAGAIN");
expect(core.settings).toMatchObject({ idDerivationVersion, idDerivationKey, hashAlg: "xxhash32" });
expect(askSelectStringDialogue).not.toHaveBeenCalled();
}
);
it.each(["active", "trial"] as const)(
"withholds ordinary tweak adoption for different document ID modes (%s)",
async (route) => {
const { module, core, askSelectStringDialogue } = createModule({
encrypt: true,
usePathObfuscation: true,
idDerivationVersion: 0,
idDerivationKey: "",
});
const preferred: TweakValues = {
...extractObject(TweakValuesTemplate, core.settings),
idDerivationVersion: 1,
};
if (route === "active") {
await expect(module._checkAndAskResolvingMismatchedTweaks(preferred)).resolves.toEqual([false, false]);
} else {
await expect(module._askUseRemoteConfiguration(core.settings, preferred)).resolves.toEqual({
result: false,
requireFetch: false,
});
}
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
expect(core.settings).toMatchObject({ idDerivationVersion: 0, idDerivationKey: "" });
}
);
it("compatibility: offers ordinary application for a missing legacy filename-case setting", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: false,
+7 -3
View File
@@ -10,7 +10,7 @@ import {
import { scheduleTask } from "octagonal-wheels/concurrency/task";
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
collectingChunks,
chunkFetchCounts,
pluginScanningCount,
hiddenFilesEventCount,
hiddenFilesProcessingCount,
@@ -36,7 +36,11 @@ import {
formatRemoteActivityStatusLabel,
getTrackedRequestCount,
} from "./RemoteActivityStatus.ts";
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
import {
createChunkFetchCounterLabel,
createMinimumVisibleActivityCount,
createPaddedCounterLabel,
} from "./StatusBarDisplay.ts";
import type { LiveSyncCore } from "@/main.ts";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import { isValidPath } from "@/common/utils.ts";
@@ -140,7 +144,7 @@ export class ModuleLog extends AbstractObsidianModule {
const labelStorageCount = registerDisplay(
createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
);
const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
const labelChunkCount = registerDisplay(createChunkFetchCounterLabel(chunkFetchCounts));
const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
const labelConflictProcessCount = registerDisplay(
createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
@@ -0,0 +1,323 @@
import { describe, expect, it, vi } from "vitest";
import { parse, stringify } from "yaml";
import {
DEFAULT_SETTINGS,
REMOTE_MINIO,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { CLOUDFLARE_TURN_TYPE } from "@/integrations/cloudflare/settings";
import { ModuleObsidianSettingsAsMarkdown } from "./ModuleObsidianSettingAsMarkdown";
vi.mock("@/deps", async () => {
const yaml = await import("yaml");
return { parseYaml: yaml.parse, stringifyYaml: yaml.stringify };
});
function createHarness(includeCredentials = false, existingFile = false) {
const settings: ObsidianLiveSyncSettings = {
...structuredClone(DEFAULT_SETTINGS),
remoteType: REMOTE_MINIO,
remoteConfigurations: {},
activeConfigurationId: "",
settingSyncFile: "LiveSync/synthetic-settings.md",
writeCredentialsForSettingSync: includeCredentials,
accessKey: "synthetic-export-access-key",
secretKey: "synthetic-export-secret-key",
couchDB_PASSWORD: "synthetic-export-couch-password",
encryptedCouchDBConnection: "synthetic-encrypted-connection",
};
const files = new Map<string, string>();
if (existingFile) {
files.set(
settings.settingSyncFile,
"Synthetic note\n````yaml:livesync-setting\n" + stringify(settings) + "\n````\n"
);
}
const storageAccess = {
isExists: vi.fn(async (path: string) => files.has(path)),
ensureDir: vi.fn(async () => true),
readFileText: vi.fn(async (path: string) => files.get(path) ?? ""),
writeFileAuto: vi.fn(async (path: string, data: string) => {
files.set(path, data);
return true;
}),
};
const module = Object.assign(Object.create(ModuleObsidianSettingsAsMarkdown.prototype), {
core: { settings, storageAccess },
_log: vi.fn(),
}) as ModuleObsidianSettingsAsMarkdown;
return { module, settings, files, storageAccess };
}
describe("ModuleObsidianSettingsAsMarkdown", () => {
it.each([false, true])(
"respects the sharing option for new and existing files (existing: %s)",
async (existingFile) => {
const { module, settings, files, storageAccess } = createHarness(false, existingFile);
settings.P2P_managedType = CLOUDFLARE_TURN_TYPE;
await module.saveSettingToMarkdown(settings.settingSyncFile);
const content = files.get(settings.settingSyncFile)!;
expect(storageAccess.writeFileAuto).toHaveBeenCalled();
const written = parse(module.extractSettingFromWholeText(content).body);
expect(written).not.toHaveProperty("couchDB_PASSWORD");
expect(content).not.toContain(settings.encryptedCouchDBConnection);
expect(Object.keys(written).filter((key) => ["accessKey", "secretKey"].includes(key))).toEqual([]);
expect(content).not.toContain(settings.accessKey);
expect(content).not.toContain(settings.secretKey);
expect(module._log).toHaveBeenCalledWith(
"When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen.",
expect.any(Number)
);
}
);
it("exports complete connection settings when sharing is enabled", async () => {
const { module, settings, files } = createHarness(true);
settings.couchDB_USER = "synthetic-export-couch-user";
settings.passphrase = "synthetic-export-passphrase";
settings.idDerivationKey = "synthetic-export-id-key";
settings.jwtKey = "synthetic-export-jwt-key";
settings.jwtKid = "synthetic-export-jwt-kid";
settings.jwtSub = "synthetic-export-jwt-subject";
settings.couchDB_CustomHeaders = "X-Couch-Export: synthetic-couch-header";
settings.bucketCustomHeaders = "X-Bucket-Export: synthetic-bucket-header";
settings.P2P_passphrase = "synthetic-export-p2p-passphrase";
settings.P2P_managedType = CLOUDFLARE_TURN_TYPE;
settings.P2P_managedId = "synthetic-managed-turn-key-id";
settings.P2P_managedToken = "synthetic-managed-turn-token";
const activeUri = ConnectionStringParser.serialize({
type: "s3",
settings: { ...settings, endpoint: "https://active.synthetic.invalid" },
});
const inactiveUri = ConnectionStringParser.serialize({
type: "s3",
settings: {
...settings,
endpoint: "https://inactive.synthetic.invalid",
accessKey: "synthetic-inactive-access-key",
secretKey: "synthetic-inactive-secret-key",
},
});
const managedTurnUri = ConnectionStringParser.serialize({
type: "p2p",
settings: {
...settings,
P2P_roomID: "synthetic-managed-turn-room",
P2P_relays: "wss://relay.synthetic.invalid",
},
});
settings.activeConfigurationId = "object-storage-active";
settings.P2P_ActiveRemoteConfigurationId = "p2p-managed";
settings.remoteConfigurations = {
"object-storage-active": {
id: "object-storage-active",
name: "Synthetic active profile",
uri: activeUri,
isEncrypted: false,
},
"object-storage-inactive": {
id: "object-storage-inactive",
name: "Synthetic inactive profile",
uri: inactiveUri,
isEncrypted: false,
},
"p2p-managed": {
id: "p2p-managed",
name: "Synthetic managed P2P profile",
uri: managedTurnUri,
isEncrypted: false,
},
};
const originalSettings = structuredClone(settings);
await module.saveSettingToMarkdown(settings.settingSyncFile);
const content = files.get(settings.settingSyncFile)!;
const written = parse(module.extractSettingFromWholeText(content).body);
expect(written.accessKey).toBe(settings.accessKey);
expect(written.secretKey).toBe(settings.secretKey);
expect(written.couchDB_USER).toBe(settings.couchDB_USER);
expect(written.couchDB_PASSWORD).toBe(settings.couchDB_PASSWORD);
expect(written.passphrase).toBe(settings.passphrase);
expect(written.idDerivationKey).toBe(settings.idDerivationKey);
expect(written.jwtKey).toBe(settings.jwtKey);
expect(written.jwtKid).toBe(settings.jwtKid);
expect(written.jwtSub).toBe(settings.jwtSub);
expect(written.couchDB_CustomHeaders).toBe(settings.couchDB_CustomHeaders);
expect(written.bucketCustomHeaders).toBe(settings.bucketCustomHeaders);
expect(written.P2P_passphrase).toBe(settings.P2P_passphrase);
expect(written.remoteConfigurations).toEqual(settings.remoteConfigurations);
expect(written.activeConfigurationId).toBe(settings.activeConfigurationId);
expect(written.P2P_ActiveRemoteConfigurationId).toBe(settings.P2P_ActiveRemoteConfigurationId);
expect(written).not.toHaveProperty("P2P_managedType");
expect(written).not.toHaveProperty("P2P_managedId");
expect(written).not.toHaveProperty("P2P_managedToken");
expect(content).toContain(
"When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen."
);
expect(content).not.toContain(settings.encryptedCouchDBConnection);
expect(settings).toEqual(originalSettings);
expect(module._log).not.toHaveBeenCalledWith(
expect.stringContaining("When credential export is disabled"),
expect.any(Number)
);
});
it("applies the sharing option to active and inactive profiles", async () => {
const { module, settings, files } = createHarness(false);
const activeUri = ConnectionStringParser.serialize({
type: "s3",
settings: { ...settings, endpoint: "https://active.synthetic.invalid" },
});
const inactiveUri = ConnectionStringParser.serialize({
type: "s3",
settings: {
...settings,
endpoint: "https://inactive.synthetic.invalid",
accessKey: "synthetic-inactive-access-key",
secretKey: "synthetic-inactive-secret-key",
},
});
const secrets = [
settings.accessKey,
settings.secretKey,
"synthetic-inactive-access-key",
"synthetic-inactive-secret-key",
];
settings.accessKey = "";
settings.secretKey = "";
settings.activeConfigurationId = "active";
settings.remoteConfigurations = {
active: { id: "active", name: "Synthetic active profile", uri: activeUri, isEncrypted: false },
inactive: { id: "inactive", name: "Synthetic inactive profile", uri: inactiveUri, isEncrypted: false },
};
settings.P2P_ActiveRemoteConfigurationId = "p2p-active";
await module.saveSettingToMarkdown(settings.settingSyncFile);
const content = files.get(settings.settingSyncFile)!;
const written = parse(module.extractSettingFromWholeText(content).body);
expect(written).not.toHaveProperty("remoteConfigurations");
expect(written).not.toHaveProperty("activeConfigurationId");
expect(written).not.toHaveProperty("P2P_ActiveRemoteConfigurationId");
expect(secrets.filter((value) => content.includes(value))).toEqual([]);
});
it("retains local connections when importing ordinary settings", async () => {
const filename = "LiveSync/synthetic-settings.md";
const activeUri = ConnectionStringParser.serialize({
type: "s3",
settings: {
...DEFAULT_SETTINGS,
endpoint: "https://active.synthetic.invalid",
accessKey: "synthetic-profile-access-key",
secretKey: "synthetic-profile-secret-key",
},
});
const inactiveUri = ConnectionStringParser.serialize({
type: "s3",
settings: {
...DEFAULT_SETTINGS,
endpoint: "https://inactive.synthetic.invalid",
accessKey: "synthetic-inactive-access-key",
secretKey: "synthetic-inactive-secret-key",
},
});
const currentSettings: ObsidianLiveSyncSettings = {
...structuredClone(DEFAULT_SETTINGS),
remoteType: REMOTE_MINIO,
settingSyncFile: filename,
writeCredentialsForSettingSync: false,
accessKey: "synthetic-local-access-key",
secretKey: "synthetic-local-secret-key",
couchDB_USER: "synthetic-local-user",
couchDB_PASSWORD: "synthetic-local-password",
couchDB_CustomHeaders: "X-Couch-Local: synthetic-couch-header",
bucketCustomHeaders: "X-Bucket-Local: synthetic-bucket-header",
jwtKey: "synthetic-local-jwt-key",
jwtKid: "synthetic-local-kid",
jwtSub: "synthetic-local-subject",
passphrase: "synthetic-local-passphrase",
idDerivationVersion: 1,
idDerivationKey: "ab".repeat(32),
activeConfigurationId: "object-storage-active",
P2P_ActiveRemoteConfigurationId: "p2p-active",
P2P_AutoStart: false,
remoteConfigurations: {
"object-storage-active": {
id: "object-storage-active",
name: "Synthetic active profile",
uri: activeUri,
isEncrypted: false,
},
"object-storage-inactive": {
id: "object-storage-inactive",
name: "Synthetic inactive profile",
uri: inactiveUri,
isEncrypted: false,
},
},
};
const markdownSettings = {
settingSyncFile: filename,
writeCredentialsForSettingSync: false,
P2P_AutoStart: true,
};
const document = "Synthetic note\n````yaml:livesync-setting\n" + stringify(markdownSettings) + "\n````\n";
const files = new Map([[filename, document]]);
const storageAccess = {
isExists: vi.fn(async (path: string) => files.has(path)),
readFileText: vi.fn(async (path: string) => files.get(path) ?? ""),
};
let openApplyDialogue: ((anchor: HTMLAnchorElement) => void) | undefined;
let applySettings: (() => void) | undefined;
const askSelectStringDialogue = vi.fn(async () => "Apply settings");
const applyExternalSettings = vi.fn(async (_settings: ObsidianLiveSyncSettings) => undefined);
const module = Object.assign(Object.create(ModuleObsidianSettingsAsMarkdown.prototype), {
core: {
settings: currentSettings,
storageAccess,
confirm: {
askInPopup: vi.fn((_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void) => {
openApplyDialogue = callback;
}),
askSelectStringDialogue,
},
rebuilder: { scheduleRebuild: vi.fn(), scheduleFetch: vi.fn() },
_services: {
setting: {
applyExternalSettings,
clearUsedPassphrase: vi.fn(),
},
appLifecycle: { performRestart: vi.fn() },
},
},
_log: vi.fn(),
}) as ModuleObsidianSettingsAsMarkdown;
await module.checkAndApplySettingFromMarkdown(filename, false);
expect(openApplyDialogue).toBeDefined();
openApplyDialogue?.({
set text(_value: string) {},
addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => {
applySettings = listener as () => void;
},
} as HTMLAnchorElement);
applySettings?.();
await vi.waitFor(() => expect(applyExternalSettings).toHaveBeenCalledOnce());
const appliedSettings = applyExternalSettings.mock.calls[0]![0];
expect(appliedSettings.P2P_AutoStart).toBe(true);
expect(appliedSettings.accessKey).toBe(currentSettings.accessKey);
expect(appliedSettings.secretKey).toBe(currentSettings.secretKey);
expect(appliedSettings.couchDB_USER).toBe(currentSettings.couchDB_USER);
expect(appliedSettings.couchDB_PASSWORD).toBe(currentSettings.couchDB_PASSWORD);
expect(appliedSettings.couchDB_CustomHeaders).toBe(currentSettings.couchDB_CustomHeaders);
expect(appliedSettings.bucketCustomHeaders).toBe(currentSettings.bucketCustomHeaders);
expect(appliedSettings.jwtKey).toBe(currentSettings.jwtKey);
expect(appliedSettings.jwtKid).toBe(currentSettings.jwtKid);
expect(appliedSettings.jwtSub).toBe(currentSettings.jwtSub);
expect(appliedSettings.remoteConfigurations).toEqual(currentSettings.remoteConfigurations);
expect(appliedSettings.activeConfigurationId).toBe(currentSettings.activeConfigurationId);
expect(appliedSettings.P2P_ActiveRemoteConfigurationId).toBe(currentSettings.P2P_ActiveRemoteConfigurationId);
expect(appliedSettings.idDerivationVersion).toBe(currentSettings.idDerivationVersion);
expect(appliedSettings.idDerivationKey).toBe(currentSettings.idDerivationKey);
});
});
@@ -7,11 +7,12 @@ import {
import { isObjectDifferent } from "octagonal-wheels/object";
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
import { fireAndForget } from "octagonal-wheels/promises";
import { type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
type FilePathWithPrefix,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
createMarkdownSettings,
mergeMarkdownSettings,
type MarkdownSettings,
} from "@vrtmrz/livesync-commonlib/settings";
import { parseYaml, stringifyYaml, type Editor, type MarkdownView } from "@/deps";
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { AbstractModule } from "@/modules/AbstractModule.ts";
@@ -132,15 +133,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
return;
}
let settingToApply = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
settingToApply = { ...settingToApply, ...newSetting };
const settingToApply = mergeMarkdownSettings(newSetting, this.settings);
preserveManagedTurnProfilesOnMarkdownImport(newSetting, this.settings, settingToApply);
if (!settingToApply?.writeCredentialsForSettingSync) {
//New setting does not contains credentials.
settingToApply.couchDB_USER = this.settings.couchDB_USER;
settingToApply.couchDB_PASSWORD = this.settings.couchDB_PASSWORD;
settingToApply.passphrase = this.settings.passphrase;
}
const oldSetting = this.generateSettingForMarkdown(
this.settings,
settingToApply.writeCredentialsForSettingSync
@@ -196,33 +190,21 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
);
}
generateSettingForMarkdown(
settings?: ObsidianLiveSyncSettings,
keepCredential?: boolean
): Partial<ObsidianLiveSyncSettings> {
generateSettingForMarkdown(settings?: ObsidianLiveSyncSettings, keepCredential?: boolean): MarkdownSettings {
const saveData = { ...(settings ? settings : this.settings) } as Partial<ObsidianLiveSyncSettings>;
delete saveData.encryptedCouchDBConnection;
delete saveData.encryptedPassphrase;
delete saveData.additionalSuffixOfDatabaseName;
if (!saveData.writeCredentialsForSettingSync && !keepCredential) {
delete saveData.couchDB_USER;
delete saveData.couchDB_PASSWORD;
delete saveData.passphrase;
delete saveData.jwtKey;
delete saveData.jwtKid;
delete saveData.jwtSub;
delete saveData.couchDB_CustomHeaders;
delete saveData.bucketCustomHeaders;
const credentialsIncluded = saveData.writeCredentialsForSettingSync || keepCredential === true;
if (!credentialsIncluded) {
omitManagedTurnProfilesFromMarkdown(saveData);
}
omitManagedTurnProfilesFromMarkdown(saveData);
return saveData;
const includeCredentials = keepCredential === undefined ? undefined : credentialsIncluded;
return createMarkdownSettings(saveData, includeCredentials);
}
async saveSettingToMarkdown(filename: string) {
const saveData = this.generateSettingForMarkdown();
if (hasManagedTurnSettings(this.settings)) {
if (!this.settings.writeCredentialsForSettingSync && hasManagedTurnSettings(this.settings)) {
this._log(
"Share TURN provider credentials through an encrypted Setup URI. Connection profiles are omitted from Markdown settings.",
"When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen.",
LOG_LEVEL_INFO
);
}
@@ -238,6 +220,8 @@ If the name of this file matches the value of the "settingSyncFile" setting insi
We can perform a command in this file.
- \`Parse setting file\` : load the setting from the file.
When credential export is disabled, connection profiles are omitted. To share them, export the settings manually from the settings screen.
**Note** Please handle it with all of your care if you have configured to write credentials in.
@@ -569,6 +569,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
}
// Internal Metadata encryption affects future Metadata writes and is not a rebuild requirement.
isNeedRebuildLocal() {
return this.isSomeDirty([
"useIndexedDBAdapter",
@@ -24,7 +24,9 @@ export function paneMaintenance(
paneEl,
"div",
{
text: $msg("The remote database is locked for synchronization to prevent vault corruption because this device isn't marked as 'resolved'. Please backup your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until the device is confirmed as resolved by replication."),
text: $msg(
"The remote database is locked for synchronisation to prevent vault corruption because this device is not marked as 'resolved'. Please back up your vault, reset the local database, and select 'Mark this device as resolved'. This warning will persist until replication confirms the device is resolved."
),
cls: "op-warn",
},
(c) => {
@@ -32,7 +34,7 @@ export function paneMaintenance(
c,
"button",
{
text: $msg("I've made a backup, mark this device 'resolved'"),
text: $msg("I have made a backup, mark this device as resolved"),
cls: "mod-warning",
},
(e) => {
@@ -51,7 +53,9 @@ export function paneMaintenance(
paneEl,
"div",
{
text: $msg("To prevent unwanted vault corruption, the remote database has been locked for synchronization. (This device is marked 'resolved') When all your devices are marked 'resolved', unlock the database. This warning kept showing until confirming the device is resolved by the replication"),
text: $msg(
"To prevent unwanted vault corruption, the remote database has been locked for synchronisation. (This device is marked as 'resolved'.) When all your devices are marked as 'resolved', unlock the database. This warning will continue to appear until replication confirms the device is resolved."
),
cls: "op-warn",
},
(c) =>
@@ -59,7 +63,7 @@ export function paneMaintenance(
c,
"button",
{
text: $msg("I'm ready, unlock the database"),
text: $msg("I am ready, unlock the database"),
cls: "mod-warning",
},
(e) => {
@@ -293,97 +297,101 @@ export function paneMaintenance(
// }
// );
void addPanel(paneEl, $msg("Rebuilding Operations (Remote Only)"), () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
new Setting(paneEl)
.setName($msg("Perform cleanup"))
.setDesc(
$msg(
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client."
void addPanel(paneEl, $msg("Rebuilding Operations (Remote Only)"), () => {}, this.onlyOnCouchDBOrMinIO).then(
(paneEl) => {
new Setting(paneEl)
.setName($msg("Perform cleanup"))
.setDesc(
$msg(
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client."
)
)
)
.addButton((button) =>
button
.setButtonText($msg("Perform"))
.setDisabled(false)
.onClick(async () => {
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
Logger(`Cleanup has been began`, LOG_LEVEL_NOTICE, "compaction");
if (await replicator.compactRemote(this.editingSettings)) {
Logger(`Cleanup has been completed!`, LOG_LEVEL_NOTICE, "compaction");
} else {
Logger(`Cleanup has been failed!`, LOG_LEVEL_NOTICE, "compaction");
}
})
)
.addOnUpdate(this.onlyOnCouchDB);
.addButton((button) =>
button
.setButtonText($msg("Perform"))
.setDisabled(false)
.onClick(async () => {
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
Logger(`Cleanup has been began`, LOG_LEVEL_NOTICE, "compaction");
if (await replicator.compactRemote(this.editingSettings)) {
Logger(`Cleanup has been completed!`, LOG_LEVEL_NOTICE, "compaction");
} else {
Logger(`Cleanup has been failed!`, LOG_LEVEL_NOTICE, "compaction");
}
})
)
.addOnUpdate(this.onlyOnCouchDB);
new Setting(paneEl)
.setName($msg("Overwrite remote"))
.setDesc($msg("Overwrite remote with local DB and passphrase."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Send"))
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("remoteOnly");
})
);
new Setting(paneEl)
.setName($msg("Overwrite remote"))
.setDesc($msg("Overwrite remote with local DB and passphrase."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Send"))
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("remoteOnly");
})
);
new Setting(paneEl)
.setName($msg("Reset all journal counter"))
.setDesc($msg("Initialise all journal history, On the next sync, every item will be received and sent."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Reset all"))
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().resetCheckpointInfo();
Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName($msg("Reset all journal counter"))
.setDesc(
$msg("Initialise all journal history, On the next sync, every item will be received and sent.")
)
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Reset all"))
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().resetCheckpointInfo();
Logger(`Journal exchange history has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName($msg("Purge all journal counter"))
.setDesc($msg("Purge all download/upload cache."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Reset all"))
.setDisabled(false)
.onClick(() => {
this.getMinioJournalSyncClient().resetAllCaches();
Logger(`Journal download/upload cache has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName($msg("Purge all journal counter"))
.setDesc($msg("Purge all download/upload cache."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Reset all"))
.setDisabled(false)
.onClick(() => {
this.getMinioJournalSyncClient().resetAllCaches();
Logger(`Journal download/upload cache has been cleared.`, LOG_LEVEL_NOTICE);
})
)
.addOnUpdate(this.onlyOnMinIO);
new Setting(paneEl)
.setName($msg("Fresh Start Wipe"))
.setDesc($msg("Delete all data on the remote server."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Delete"))
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({
...info,
receivedFiles: new Set(),
knownIDs: new Set(),
lastLocalSeq: 0,
sentIDs: new Set(),
sentFiles: new Set(),
}));
const reset = await this.resetRemoteBucket();
Logger(
reset
? `Deleted all data on remote server`
: `Fresh Start Wipe did not complete. Keep all synchronising devices stopped and run it again.`,
LOG_LEVEL_NOTICE
);
})
)
.addOnUpdate(this.onlyOnMinIO);
});
new Setting(paneEl)
.setName($msg("Fresh Start Wipe"))
.setDesc($msg("Delete all data on the remote server."))
.addButton((button) =>
setButtonDestructiveState(button)
.setButtonText($msg("Delete"))
.setDisabled(false)
.onClick(async () => {
await this.getMinioJournalSyncClient().updateCheckPointInfo((info) => ({
...info,
receivedFiles: new Set(),
knownIDs: new Set(),
lastLocalSeq: 0,
sentIDs: new Set(),
sentFiles: new Set(),
}));
const reset = await this.resetRemoteBucket();
Logger(
reset
? `Deleted all data on remote server`
: `Fresh Start Wipe did not complete. Keep all synchronising devices stopped and run it again.`,
LOG_LEVEL_NOTICE
);
})
)
.addOnUpdate(this.onlyOnMinIO);
}
);
void addPanel(paneEl, $msg("Reset")).then((paneEl) => {
new Setting(paneEl)
@@ -47,6 +47,12 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
}
return workObj;
}
function syncIdDerivationSettings(target: Partial<ObsidianLiveSyncSettings>, source: ObsidianLiveSyncSettings): void {
target.idDerivationVersion = source.idDerivationVersion;
target.idDerivationKey = source.idDerivationKey;
}
function createRemoteConfigurationId(): string {
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
@@ -116,7 +122,28 @@ export function paneRemoteConfig(
.onClick(async () => {
const setupManager = this.core.getModule(SetupManager);
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
const originalIdDerivationVersion = this.core.settings.idDerivationVersion;
const originalIdDerivationKey = this.core.settings.idDerivationKey;
const applied = await setupManager.onlyE2EEConfiguration(UserMode.Update, originalSettings);
if (applied) {
this.editingSettings.encryptInternalMetadata =
this.core.settings.encryptInternalMetadata;
if (this.initialSettings) {
this.initialSettings.encryptInternalMetadata =
this.core.settings.encryptInternalMetadata;
}
this.requestUpdate();
}
if (
this.core.settings.idDerivationVersion !== originalIdDerivationVersion ||
this.core.settings.idDerivationKey !== originalIdDerivationKey
) {
syncIdDerivationSettings(this.editingSettings, this.core.settings);
if (this.initialSettings) {
syncIdDerivationSettings(this.initialSettings, this.core.settings);
}
this.requestUpdate();
}
updateE2EESummary();
})
.setButtonText($msg("Configure"))
@@ -155,9 +182,11 @@ export function paneRemoteConfig(
const currentConfigs = cloneRemoteConfigurations(this.core.settings.remoteConfigurations);
this.editingSettings.remoteConfigurations = currentConfigs;
this.editingSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
syncIdDerivationSettings(this.editingSettings, this.core.settings);
if (this.initialSettings) {
this.initialSettings.remoteConfigurations = cloneRemoteConfigurations(currentConfigs);
this.initialSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
syncIdDerivationSettings(this.initialSettings, this.core.settings);
}
};
const persistRemoteConfigurations = async (synchroniseActiveRemote: boolean = false) => {
@@ -243,7 +272,10 @@ export function paneRemoteConfig(
...DEFAULT_SETTINGS,
encrypt: this.editingSettings.encrypt,
usePathObfuscation: this.editingSettings.usePathObfuscation,
encryptInternalMetadata: this.editingSettings.encryptInternalMetadata,
passphrase: this.editingSettings.passphrase,
idDerivationVersion: this.editingSettings.idDerivationVersion,
idDerivationKey: this.editingSettings.idDerivationKey,
configPassphraseStore: this.editingSettings.configPassphraseStore,
});
const addRemoteConfiguration = async () => {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const runtime = vi.hoisted(() => ({
buttonClasses: [] as string[],
clickHandlers: [] as Array<() => Promise<void> | void>,
panels: [] as Array<{ destroy: ReturnType<typeof vi.fn> }>,
settingClasses: [] as string[],
}));
@@ -51,7 +52,8 @@ vi.mock("./LiveSyncSetting.ts", () => ({
setDestructive() {
return this;
},
onClick() {
onClick(callback: () => Promise<void> | void) {
runtime.clickHandlers.push(callback);
return this;
},
setButtonText() {
@@ -97,6 +99,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/ConnectionString", () => ({
},
}));
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
vi.mock("@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
@@ -114,6 +117,7 @@ function createPanelElement(): HTMLElement {
afterEach(() => {
runtime.buttonClasses.length = 0;
runtime.clickHandlers.length = 0;
runtime.panels.length = 0;
runtime.settingClasses.length = 0;
vi.clearAllMocks();
@@ -148,4 +152,93 @@ describe("paneRemoteConfig", () => {
expect(runtime.panels[0].destroy).toHaveBeenCalledOnce();
});
it("applies an internal Metadata preference change without scheduling setup initialisation", async () => {
const originalSettings = {
encrypt: true,
passphrase: "passphrase",
E2EEAlgorithm: "v2",
usePathObfuscation: true,
encryptInternalMetadata: false,
remoteConfigurations: {},
};
const setupManager = {
onlyE2EEConfiguration: vi.fn(async () => {
host.core.settings.encryptInternalMetadata = true;
return true;
}),
};
const host = {
editingSettings: { ...originalSettings },
initialSettings: { ...originalSettings },
core: {
settings: { ...originalSettings },
getModule: vi.fn(() => setupManager),
},
lifetimeComponent: { register: vi.fn() },
requestUpdate: vi.fn(),
};
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "E2EE Configuration") {
callback(createPanelElement());
}
},
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
expect(setupManager.onlyE2EEConfiguration).toHaveBeenCalledOnce();
expect(host.editingSettings.encryptInternalMetadata).toBe(true);
expect(host.initialSettings.encryptInternalMetadata).toBe(true);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
it("copies applied ID derivation settings into both dialogue buffers", async () => {
const nextIdKey = "ab".repeat(32);
const originalSettings = {
encrypt: true,
passphrase: "passphrase",
E2EEAlgorithm: "v2",
usePathObfuscation: true,
encryptInternalMetadata: false,
idDerivationVersion: 0,
idDerivationKey: "",
remoteConfigurations: {},
};
const setupManager = {
onlyE2EEConfiguration: vi.fn(() => {
host.core.settings.idDerivationVersion = 1;
host.core.settings.idDerivationKey = nextIdKey;
return Promise.resolve(false);
}),
};
const host = {
editingSettings: { ...originalSettings },
initialSettings: { ...originalSettings },
core: {
settings: { ...originalSettings },
getModule: vi.fn(() => setupManager),
},
lifetimeComponent: { register: vi.fn() },
requestUpdate: vi.fn(),
};
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
then(callback: (paneEl: HTMLElement) => void) {
if (heading === "E2EE Configuration") {
callback(createPanelElement());
}
},
}));
paneRemoteConfig.call(host as never, {} as HTMLElement, { addPanel } as never);
await runtime.clickHandlers[0]();
expect(host.editingSettings.idDerivationVersion).toBe(1);
expect(host.editingSettings.idDerivationKey).toBe(nextIdKey);
expect(host.initialSettings.idDerivationVersion).toBe(1);
expect(host.initialSettings.idDerivationKey).toBe(nextIdKey);
expect(host.requestUpdate).toHaveBeenCalledOnce();
});
});
@@ -32,7 +32,9 @@ export function paneSelector(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
const nonSyncFilesSetting = new Setting(paneEl)
.setName($msg("Non-Synchronising files"))
.setDesc($msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped."));
.setDesc(
$msg("(RegExp) If this is set, any changes to local and remote files that match this will be skipped.")
);
const nonSyncFilesControl = mount(MultipleRegExpControl, {
target: nonSyncFilesSetting.controlEl,
@@ -68,6 +68,7 @@ export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdva
export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncSettings>, showAdvanced = false) {
const outputSummary: Record<string, string> = {};
for (const key of Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]) {
if (key === "idDerivationKey" || key === "encryptedIdDerivationKey") continue;
const config = getConfig(key as AllSettingItemKey);
if (!config) continue;
if (config.isAdvanced && !showAdvanced) continue;
+60 -7
View File
@@ -1,6 +1,5 @@
import {
type BucketSyncSetting,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
LOG_LEVEL_NOTICE,
@@ -36,6 +35,7 @@ import type {
SetupRemoteCouchDBResultType,
SetupRemoteCouchDBInitialData,
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData,
SetupRemoteP2PInitialData,
SetupRemoteP2PResultType,
SetupRemoteResultType,
@@ -58,6 +58,20 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
};
}
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
return {
...settings,
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
? settings.idDerivationVersion
: 0,
idDerivationKey: Object.prototype.hasOwnProperty.call(settings, "idDerivationKey")
? settings.idDerivationKey
: "",
};
}
/**
* User modes for onboarding and setup
*/
@@ -219,7 +233,7 @@ export class SetupManager extends AbstractModule {
return false;
}
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
}
/**
@@ -328,14 +342,44 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
currentSetting
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
);
if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return false;
}
const onlyInternalMetadataPreferenceChanged =
currentSetting.encryptInternalMetadata !== e2eeConf.encryptInternalMetadata &&
currentSetting.encrypt === e2eeConf.encrypt &&
currentSetting.passphrase === e2eeConf.passphrase &&
currentSetting.E2EEAlgorithm === e2eeConf.E2EEAlgorithm &&
currentSetting.usePathObfuscation === e2eeConf.usePathObfuscation &&
currentSetting.idDerivationVersion === e2eeConf.idDerivationVersion &&
currentSetting.idDerivationKey === e2eeConf.idDerivationKey;
if (userMode === UserMode.Update && onlyInternalMetadataPreferenceChanged) {
if (e2eeConf.encryptInternalMetadata && currentSetting.remoteType === REMOTE_COUCHDB) {
const proceed = "Enable without rebuilding — update every other device first";
const choice = await this.core.confirm.askSelectStringDialogue(
"A manual remote Rebuild is strongly recommended to protect existing file properties. " +
"Before continuing without rebuilding, update every other synchronising device to a version " +
"which supports this option, including devices currently running LiveSync. " +
"Existing properties remain unchanged until they are rewritten or rebuilt.",
[proceed, "Cancel"],
{ title: "Encrypt internal file Properties", defaultAction: "Cancel" }
);
if (choice !== proceed) return false;
}
await this.services.setting.applyPartial(
{ encryptInternalMetadata: e2eeConf.encryptInternalMetadata },
true
);
return true;
}
const newSetting = {
...currentSetting,
...e2eeConf,
@@ -350,9 +394,12 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
originalSetting
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
);
if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
@@ -496,7 +543,13 @@ export class SetupManager extends AbstractModule {
* @returns Promise that resolves to true if settings applied successfully, false otherwise
*/
async decodeQR(qr: string) {
const newSettings = decodeSettingsFromQRCodeData(qr);
let newSettings: ObsidianLiveSyncSettings;
try {
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
} catch {
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
return false;
}
return await this.onConfirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
}
@@ -193,6 +193,58 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "12".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
const imported = {
...createLegacyRemoteSetting(),
isConfigured: true,
} as Partial<ObsidianLiveSyncSettings>;
delete imported.idDerivationVersion;
delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
it("does not inherit the missing half of a partially present Setup URI ID configuration", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "34".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
const imported = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
} as Partial<ObsidianLiveSyncSettings>;
delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(imported).mockResolvedValueOnce("cancelled");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://partial-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(1);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
it("compatibility: normalises imported flat remote settings from QR data before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
@@ -208,6 +260,79 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("compatibility: applies legacy defaults when QR data omits ID derivation fields", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "56".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
const imported = { ...createLegacyRemoteSetting(), isConfigured: true } as Partial<ObsidianLiveSyncSettings>;
delete imported.idDerivationVersion;
delete imported.idDerivationKey;
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(imported as ObsidianLiveSyncSettings);
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
await manager.decodeQR("qr-data");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
});
it("rejects invalid QR settings before applying them", async () => {
const { manager, setting } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockImplementationOnce(() => {
throw new Error("Invalid ID derivation key");
});
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
await expect(manager.decodeQR("invalid-qr")).resolves.toBe(false);
expect(applyExternalSettings).not.toHaveBeenCalled();
});
it("requires the normal Fetch choice when ID derivation changes with the Metadata preference", async () => {
const { manager, setting, dialogManager, core } = createSetupManager();
const currentSettings: ObsidianLiveSyncSettings = {
...createLegacyRemoteSetting(),
isConfigured: true,
encrypt: true,
passphrase: "e2ee-passphrase",
usePathObfuscation: true,
encryptInternalMetadata: false,
idDerivationVersion: 0,
idDerivationKey: "",
};
const nextIdKey = "78".repeat(32);
setting.settings = currentSettings;
const applyPartial = vi.spyOn(setting, "applyPartial");
core.confirm = {
askSelectStringDialogue: vi.fn(() =>
Promise.resolve("Enable without rebuilding — update every other device first")
),
};
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce({
...currentSettings,
encryptInternalMetadata: true,
idDerivationVersion: 1,
idDerivationKey: nextIdKey,
})
.mockResolvedValueOnce("existing-user")
.mockResolvedValueOnce("apply");
await manager.onlyE2EEConfiguration(UserMode.Update, currentSettings);
expect(applyPartial).not.toHaveBeenCalled();
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
expect(setting.currentSettings().idDerivationVersion).toBe(1);
expect(setting.currentSettings().idDerivationKey).toBe(nextIdKey);
});
it("reserves Rebuild before saving a new-user configuration", async () => {
const { manager, setting, dialogManager, core } = createSetupManager();
setting.settings = { ...setting.currentSettings(), isConfigured: false };
@@ -659,3 +784,23 @@ describe("SetupManager", () => {
expect(setting.currentSettings().P2P_ActiveRemoteConfigurationId).toBe("existing");
});
});
describe("internal Metadata configuration", () => {
it.each([true, false])(
"applies the preference only after accepting the no-Rebuild warning (%s)",
async (accept) => {
const { manager, setting, dialogManager, core } = createSetupManager();
const current = { ...setting.settings, encryptInternalMetadata: false, remoteType: REMOTE_COUCHDB };
dialogManager.openWithExplicitCancel.mockResolvedValue({ ...current, encryptInternalMetadata: true });
const ask = vi.fn(async (_message: string, choices: string[]) => (accept ? choices[0] : "Cancel"));
core.confirm = { askSelectStringDialogue: ask };
const apply = vi.spyOn(setting, "applyPartial").mockResolvedValue(undefined);
await expect(manager.onlyE2EEConfiguration(UserMode.Update, current)).resolves.toBe(accept);
expect(ask.mock.calls[0][1][0]).toContain("update every other device first");
expect(ask.mock.calls[0][0]).toContain("currently running LiveSync");
expect(apply).toHaveBeenCalledTimes(accept ? 1 : 0);
expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
}
);
});
@@ -48,9 +48,7 @@
<Instruction>
<Question>{translateMessage("Please select your situation.")}</Question>
<Option
title={translateMessage(
"I am setting up a new server for the first time / I want to reset my existing server."
)}
title={translateMessage("⚠️ Initialise or overwrite the remote")}
bind:value={userType}
selectedValue={TYPE_NEW}
>
@@ -61,7 +59,7 @@
</InfoNote>
</Option>
<Option
title={translateMessage("My remote server is already set up. I want to join this device.")}
title={translateMessage("🔗 Join this device")}
bind:value={userType}
selectedValue={TYPE_EXISTING}
>
@@ -72,9 +70,7 @@
</InfoNote>
</Option>
<Option
title={translateMessage(
"The remote is already set up, and the configuration is compatible (or got compatible by this operation)."
)}
title={translateMessage("⚙️ Apply settings only (advanced)")}
bind:value={userType}
selectedValue={TYPE_COMPATIBLE_EXISTING}
>
@@ -13,38 +13,144 @@
E2EEAlgorithms,
type EncryptionSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
deriveIdKey,
deriveOrImportIdKey,
formatIdRecoveryCode,
ID_DERIVATION_VERSION,
ID_RECOVERY_CODE_PREFIX,
} from "@vrtmrz/livesync-commonlib/settings";
import { onMount } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import { copyTo, pickEncryptionSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
import {
TYPE_CANCELLED,
type SetupRemoteE2EEInitialData,
type SetupRemoteE2EEResultType,
} from "./setupDialogTypes";
import { $msg as translateMessage } from "@/common/translation";
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
type Props = GuestDialogProps<SetupRemoteE2EEResultType, SetupRemoteE2EEInitialData>;
type IdConfigurationChoice = "keep" | "random" | "custom";
type IdCustomChoice = "passphrase" | "source" | "recovery";
const { setResult, getInitialData }: Props = $props();
let default_encryption: EncryptionSettings = {
encrypt: true,
passphrase: "",
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
usePathObfuscation: true,
} as EncryptionSettings;
encryptInternalMetadata: true,
idDerivationVersion: 0,
idDerivationKey: "",
};
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
let newVault = $state(false);
let idConfigurationChoice = $state<IdConfigurationChoice>("keep");
let idCustomChoice = $state<IdCustomChoice>("source");
let idDerivationSource = $state("");
let idDerivationError = $state("");
let recoveryCodeVisible = $state(false);
let recoveryCodeCopied = $state(false);
const idDerivationConfigured = $derived(
encryptionSettings.idDerivationVersion === ID_DERIVATION_VERSION &&
typeof encryptionSettings.idDerivationKey === "string" &&
encryptionSettings.idDerivationKey.length > 0
);
const recoveryCode = $derived.by(() =>
idDerivationConfigured ? formatIdRecoveryCode(encryptionSettings.idDerivationKey) : ""
);
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, encryptionSettings);
copyTo(initialData.settings, encryptionSettings);
newVault = initialData.newVault;
}
}
idConfigurationChoice = !idDerivationConfigured && newVault ? "random" : "keep";
});
let e2eeValid = $derived.by(() => {
if (!encryptionSettings.encrypt) return true;
return encryptionSettings.passphrase.trim().length >= 1;
});
let canEncryptInternalMetadata = $derived(
encryptionSettings.encrypt &&
encryptionSettings.E2EEAlgorithm === E2EEAlgorithms.V2 &&
encryptionSettings.usePathObfuscation
);
function commit() {
setResult(pickEncryptionSettings(encryptionSettings));
function resetIdDerivationSource() {
idDerivationSource = "";
idDerivationError = "";
}
function toggleEncryption(enabled: boolean) {
encryptionSettings.encrypt = enabled;
if (!enabled) resetIdDerivationSource();
}
function selectIdConfiguration() {
recoveryCodeVisible = false;
recoveryCodeCopied = false;
resetIdDerivationSource();
}
function selectIdCustomSource() {
resetIdDerivationSource();
}
async function copyRecoveryCode() {
try {
await navigator.clipboard.writeText(recoveryCode);
recoveryCodeCopied = true;
} catch {
idDerivationError = translateMessage("The recovery code could not be copied. Select and copy the visible code instead.");
}
}
async function commit() {
idDerivationError = "";
const result = pickEncryptionSettings(encryptionSettings);
if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") {
let source = idDerivationSource;
if (idConfigurationChoice === "random") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
source = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
} else if (idCustomChoice === "passphrase") {
source = encryptionSettings.passphrase;
}
if (source.length === 0) {
if (!idDerivationConfigured) {
idDerivationError = translateMessage("An ID source is required to enable this option.");
return;
}
} else {
try {
result.idDerivationKey =
idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"
? await importOrDeriveEnteredIdKey(source, idCustomChoice)
: await deriveIdKey(source);
result.idDerivationVersion = ID_DERIVATION_VERSION;
} catch {
idDerivationError = translateMessage("The ID source or recovery code is invalid. Check it and try again.");
return;
}
}
}
idDerivationSource = "";
setResult(result);
}
async function importOrDeriveEnteredIdKey(source: string, choice: IdCustomChoice): Promise<string> {
if (choice === "recovery" && !source.trim().startsWith(ID_RECOVERY_CODE_PREFIX)) {
throw new Error("An ID recovery code is required.");
}
return await deriveOrImportIdKey(source);
}
</script>
@@ -52,7 +158,11 @@
<DialogHeader title={translateMessage("End-to-End Encryption")} />
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
<InputRow label={translateMessage("End-to-End Encryption")}>
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
<input
type="checkbox"
checked={encryptionSettings.encrypt}
onchange={(event) => toggleEncryption(event.currentTarget.checked)}
/>
</InputRow>
<InfoNote title={translateMessage("Strongly Recommended")}>
{translateMessage(
@@ -87,6 +197,182 @@
</InfoNote>
{/if}
<fieldset class="sls-id-choices" disabled={!encryptionSettings.encrypt}>
<legend>{translateMessage("ID generation")}</legend>
<label class="sls-id-choice">
<input
type="radio"
name="id-derivation-choice"
value="keep"
bind:group={idConfigurationChoice}
onchange={selectIdConfiguration}
/>
<div class="sls-id-choice-text">
<span>{translateMessage("Keep current configuration")}</span>
<small class="sls-current-id-configuration">
{#if idDerivationConfigured}
{translateMessage(
encryptionSettings.encrypt
? "Current configuration: a saved ID key is used."
: "Current configuration: the saved ID key is retained while E2EE is off."
)}
{:else}
{translateMessage(
"Current configuration: no ID key is saved. With E2EE enabled, keeping it uses legacy IDs tied to the E2EE passphrase."
)}
{/if}
</small>
</div>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-derivation-choice"
value="random"
bind:group={idConfigurationChoice}
onchange={selectIdConfiguration}
/>
<span>{translateMessage("Generate a random ID key")}</span>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-derivation-choice"
value="custom"
bind:group={idConfigurationChoice}
onchange={selectIdConfiguration}
/>
<span>{translateMessage("Set an ID key")}</span>
</label>
</fieldset>
{#if encryptionSettings.encrypt && idConfigurationChoice === "keep" && !idDerivationConfigured}
<InfoNote warning>
{translateMessage("Changing the E2EE passphrase changes IDs generated by the legacy configuration.")}
</InfoNote>
{/if}
{#if (encryptionSettings.encrypt && idConfigurationChoice !== "keep") || idDerivationConfigured}
{#if encryptionSettings.encrypt}
<InfoNote>
{translateMessage(
"This uses a saved key for new Chunk IDs and obfuscated Metadata document IDs, so changing the E2EE passphrase does not derive a new key automatically."
)}
</InfoNote>
{/if}
{#if idDerivationConfigured}
<InfoNote title={translateMessage("Configured")}>
{translateMessage("The saved ID key is configured. Its source cannot be shown again.")}
</InfoNote>
<button type="button" onclick={() => (recoveryCodeVisible = !recoveryCodeVisible)}>
{translateMessage(recoveryCodeVisible ? "Hide current recovery code" : "Show current recovery code")}
</button>
{#if recoveryCodeVisible}
<InputRow label={translateMessage("Current ID recovery code")}>
<input type="text" readonly value={recoveryCode} aria-label={translateMessage("Current ID recovery code")} />
<button type="button" onclick={copyRecoveryCode}>{translateMessage("Copy recovery code")}</button>
</InputRow>
{#if recoveryCodeCopied}
<InfoNote>{translateMessage("Recovery code copied.")}</InfoNote>
{/if}
{/if}
{/if}
{#if encryptionSettings.encrypt}
{#if idConfigurationChoice === "custom"}
<fieldset class="sls-id-choices sls-id-custom-choices">
<legend>{translateMessage("How to set the ID key")}</legend>
<label class="sls-id-choice">
<input
type="radio"
name="id-custom-choice"
value="passphrase"
bind:group={idCustomChoice}
onchange={selectIdCustomSource}
/>
<span>{translateMessage("Derive from current E2EE passphrase")}</span>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-custom-choice"
value="source"
bind:group={idCustomChoice}
onchange={selectIdCustomSource}
/>
<span>{translateMessage("Enter an ID source")}</span>
</label>
<label class="sls-id-choice">
<input
type="radio"
name="id-custom-choice"
value="recovery"
bind:group={idCustomChoice}
onchange={selectIdCustomSource}
/>
<span>{translateMessage("Import an ID recovery code")}</span>
</label>
</fieldset>
{#if idCustomChoice === "source" || idCustomChoice === "recovery"}
<InputRow
label={translateMessage(idCustomChoice === "source" ? "ID source" : "ID recovery code")}
>
<Password
name="id-derivation-source"
placeholder={translateMessage(
idCustomChoice === "source" ? "Enter an ID source" : "Enter an ID recovery code"
)}
bind:value={idDerivationSource}
/>
</InputRow>
{/if}
{/if}
{#if idDerivationConfigured && idConfigurationChoice !== "keep"}
<InfoNote>
{translateMessage("The displayed recovery code belongs to the current key. Reopen this dialogue after saving to copy the replacement key.")}
</InfoNote>
{/if}
{#if idConfigurationChoice === "custom" && idCustomChoice === "source"}
<InfoNote>
{translateMessage("Choose a long, unpredictable source. It is used once and cannot be shown again after saving. A recovery code can be displayed on this device later. This input also accepts a tagged recovery code.")}
</InfoNote>
{:else if idConfigurationChoice === "custom" && idCustomChoice === "recovery"}
<InfoNote>
{translateMessage("Paste a tagged recovery code from an existing device to restore the same ID key.")}
</InfoNote>
{:else if idConfigurationChoice === "random"}
<InfoNote warning>
{translateMessage("For recovery after losing every device, save the recovery code after setup or choose an ID source you can reproduce.")}
</InfoNote>
{:else if idConfigurationChoice === "custom" && idCustomChoice === "passphrase"}
<InfoNote warning>
{translateMessage(
"The ID key is derived from the current E2EE passphrase and saved separately. Changing that passphrase later does not change the saved ID key. To reduce the risk of guessing that passphrase from known IDs, use a separate, unpredictable ID source instead."
)}
</InfoNote>
{/if}
{#if idDerivationConfigured && idConfigurationChoice === "custom" && idCustomChoice !== "passphrase"}
<InfoNote>{translateMessage("Leave this input empty to keep the saved ID key.")}</InfoNote>
{/if}
{/if}
<InfoNote error visible={idDerivationError !== ""}>{idDerivationError}</InfoNote>
{/if}
<InputRow label="Encrypt internal file Properties">
<input
type="checkbox"
bind:checked={encryptionSettings.encryptInternalMetadata}
disabled={!canEncryptInternalMetadata}
/>
</InputRow>
<InfoNote>
This option encrypts file properties used by Hidden File Sync and Customisation Sync.
<br />
It applies only to CouchDB and requires End-to-End Encryption, the V2 algorithm, and Property Encryption
(Obfuscate Properties). The remote type is selected later in this setup wizard.
<br />
It protects properties written after the option is enabled; existing properties are not rewritten. A manual remote
Rebuild is strongly recommended to protect existing properties. Update every other synchronising device to a compatible
version before enabling this option, including devices currently running LiveSync.
</InfoNote>
<ExtraItems title={translateMessage("Advanced")}>
<InputRow label={translateMessage("Encryption Algorithm")}>
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
@@ -138,4 +424,41 @@
width: auto;
min-width: 8em;
}
.sls-id-choices {
border: 0;
display: flex;
flex-direction: column;
gap: 0.35em;
margin: 0;
min-width: 0;
padding: 0;
}
.sls-id-choices legend {
margin-bottom: 0.35em;
}
.sls-id-choices:disabled {
opacity: 0.6;
}
.sls-id-custom-choices {
margin-left: 1.5em;
}
.sls-id-choice {
align-items: flex-start;
display: flex;
gap: 0.5em;
}
.sls-id-choice input[type="radio"] {
flex: none;
margin-top: 0.25em;
}
.sls-id-choice-text {
display: flex;
flex-direction: column;
}
.sls-current-id-configuration {
color: var(--text-muted);
display: block;
font-size: var(--font-ui-smaller);
margin-top: 0.15em;
}
</style>
@@ -110,6 +110,10 @@ export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | t
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
export type SetupRemoteE2EEInitialData = {
settings: EncryptionSettings;
newVault: boolean;
};
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
+47
View File
@@ -131,3 +131,50 @@ export function createPaddedCounterLabel(
source.offChanged(update);
});
}
/**
* Displays the disjoint initial and retry chunk-fetch counts with the same
* padding and inactive linger behaviour as the other status counters.
*/
export function createChunkFetchCounterLabel(
source: ReactiveValue<{ initial: number; retrying: number }>
): DisposableReactiveValue<string> {
const initialCount = reactiveSource(0);
const retryingCount = reactiveSource(0);
const initialLabel = createPaddedCounterLabel(initialCount, "🛄");
const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁");
const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`);
let updatingCounts = false;
let disposed = false;
const updateLabel = () => {
if (updatingCounts || disposed) return;
formatted.value = `${initialLabel.value}${retryingLabel.value}`;
};
initialLabel.onChanged(updateLabel);
retryingLabel.onChanged(updateLabel);
const updateCounts = () => {
if (disposed) return;
updatingCounts = true;
try {
initialCount.value = source.value.initial;
retryingCount.value = source.value.retrying;
} finally {
updatingCounts = false;
updateLabel();
}
};
source.onChanged(updateCounts);
updateCounts();
return asDisposableReactiveValue(formatted, () => {
if (disposed) return;
disposed = true;
source.offChanged(updateCounts);
initialLabel.offChanged(updateLabel);
retryingLabel.offChanged(updateLabel);
initialLabel.dispose();
retryingLabel.dispose();
});
}
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
STATUS_COUNTER_INACTIVE_LINGER_MS,
createChunkFetchCounterLabel,
createMinimumVisibleActivityCount,
createPaddedCounterLabel,
} from "./StatusBarDisplay.ts";
@@ -137,3 +138,41 @@ describe("createPaddedCounterLabel", () => {
expect(display.value).toBe(" 📄\u20070");
});
});
describe("createChunkFetchCounterLabel", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("keeps initial and retry counts separate across an unchanged-total handoff", () => {
const counts = reactiveSource({ initial: 2, retrying: 5 });
const display = createChunkFetchCounterLabel(counts);
const withoutPadding = () => display.value.replace(/\u2007/g, "");
const transitionSnapshots: string[] = [];
const observeTransitions = () => transitionSnapshots.push(withoutPadding());
expect(withoutPadding()).toBe(" 🛄2 🔁5");
display.onChanged(observeTransitions);
counts.value = { initial: 0, retrying: 7 };
expect(withoutPadding()).toBe(" 🛄0 🔁7");
expect(transitionSnapshots).toEqual([" 🛄0 🔁7"]);
display.offChanged(observeTransitions);
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
expect(withoutPadding()).toBe(" 🛄0 🔁7");
vi.advanceTimersByTime(1);
expect(withoutPadding()).toBe(" 🔁7");
counts.value = { initial: 0, retrying: 0 };
expect(withoutPadding()).toBe(" 🔁0");
display.dispose();
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
counts.value = { initial: 1, retrying: 0 };
expect(withoutPadding()).toBe(" 🔁0");
});
});
@@ -57,4 +57,9 @@
textarea {
resize: none;
}
button {
min-width: 44px;
min-height: 44px;
}
</style>
+2 -3
View File
@@ -66,9 +66,8 @@ export class CompatibilityReviewController {
// An existing unconfigured Vault cannot replicate, so a database
// compatibility pause would only compete with onboarding and persist
// a misleading sync warning. Do not acknowledge the missing marker:
// activation on a later start must evaluate the same state again.
// Genuinely new Vaults still initialise their marker below.
// a misleading sync warning. Its next configured start evaluates any
// known compatibility reasons before initialising an absent marker.
if (settings.isConfigured !== true && migrationState?.isNewVault !== true) {
this.pause = undefined;
this.ui.clearReminder();
@@ -103,15 +103,43 @@ describe("compatibility review controller", () => {
fixture.settings.isConfigured = true;
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.controller.pendingPause?.reasons).toContainEqual({
source: "database-version",
state: "missing",
currentVersion: 12,
resumable: true,
});
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings.versionUpFlash).toBe("");
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.saveSettingData).not.toHaveBeenCalled();
});
it("starts a configured Vault with no device marker without a review or settings changes", async () => {
const fixture = createFixture({ marker: null });
const previousSettings = { ...fixture.settings };
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.settings).toEqual(previousSettings);
expect(fixture.saveSettingData).not.toHaveBeenCalled();
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
expect(fixture.ui.showReminder).not.toHaveBeenCalled();
});
it("keeps an already persisted review when the device marker is absent", async () => {
const fixture = createFixture({ marker: null, versionUpFlash: COMPATIBILITY_PAUSE_SETTING_MESSAGE });
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.controller.pendingPause?.reasons).toEqual([
{
source: "legacy-review",
message: COMPATIBILITY_PAUSE_SETTING_MESSAGE,
resumable: true,
},
]);
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
expect(fixture.saveSettingData).toHaveBeenCalledOnce();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
});
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
@@ -19,9 +19,6 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
if (reason.state === "downgrade") {
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
}
if (reason.state === "missing") {
return `- No previously acknowledged internal database version was found for this existing Vault. This can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`;
}
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
}
if (reason.source === "settings-schema") {
@@ -23,13 +23,13 @@ const resumablePause: CompatibilityPause = {
};
describe("Obsidian compatibility review", () => {
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
it("explains an invalid device-local acknowledgement", () => {
const pause: CompatibilityPause = {
resumable: true,
reasons: [
{
source: "database-version",
state: "missing",
state: "invalid",
currentVersion: 12,
resumable: true,
},
@@ -37,9 +37,8 @@ describe("Obsidian compatibility review", () => {
};
const details = compatibilityReviewDetailsMarkdown(pause);
expect(details).toContain("copied or restored");
expect(details).toContain("new Obsidian profile");
expect(details).toContain("does not mean that it is safe to resume automatically");
expect(details).toContain("saved internal database version marker is invalid");
expect(details).toContain("version **12**");
});
it("offers the generic resume action in a vertical action dialogue", async () => {
@@ -1,6 +1,7 @@
import { assessRemoteFeatureDocument, describeRemoteFeatureRejection } from "@vrtmrz/livesync-commonlib/replication";
import {
SYNCINFO_ID,
VER,
VERSIONING_DOCID,
type AnyEntry,
type EntryDoc,
type EntryLeaf,
@@ -107,8 +108,15 @@ export class ReplicateResultProcessor {
}
public resume() {
this._suspended = false;
this.continueHeldDocuments();
}
/**
* Continue the queued documents which were held, for example while the application was not ready.
* An explicit suspension, by `suspend()` or by the settings, remains in effect.
*/
public continueHeldDocuments() {
this.updateProcessingActivity();
fireAndForget(() => this.runProcessQueue());
this.triggerProcessQueue();
}
// Whether the processing is suspended
@@ -274,13 +282,14 @@ export class ReplicateResultProcessor {
this.log(`Processed chunk: ${shortenId(change._id)}`, LOG_LEVEL_DEBUG);
return true;
}
if (change.type == "versioninfo") {
if (change._id === VERSIONING_DOCID || change.type === "versioninfo") {
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
if (change.version > VER) {
const assessment = assessRemoteFeatureDocument(change);
if (assessment.status !== "supported" && assessment.status !== "older-generation") {
// Fence and retire the active publication through its owner.
this.context.requestActiveReplicatorRetirement();
this.log(
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
`${describeRemoteFeatureRejection(assessment)} Update Self-hosted LiveSync before synchronising.`,
LOG_LEVEL_NOTICE
);
}
@@ -1,7 +1,12 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { describe, expect, it, vi } from "vitest";
import { VER, type EntryDoc, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
VERSIONING_DOCID,
type EntryDoc,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
import {
isValidFilenameInAndroid,
isValidFilenameInWidows,
@@ -37,6 +42,10 @@ type SetupOptions = {
isValidPath?: (path: string) => boolean;
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
getSnapshot?: (key: string) => Promise<unknown>;
localVersionInfo?: unknown;
isTargetFile?: (path: string) => Promise<boolean>;
databaseId?: string;
};
function setup(options: SetupOptions = {}) {
@@ -47,6 +56,9 @@ function setup(options: SetupOptions = {}) {
const isReady = vi.fn(() => options.applicationReady ?? true);
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
const localPhysicalDatabase = {
...(options.databaseId ? { id: vi.fn(async () => options.databaseId) } : {}),
} as PouchDB.Database<EntryDoc>;
const core = {
services: {
appLifecycle: { isReady, isSuspended: () => false },
@@ -62,14 +74,21 @@ function setup(options: SetupOptions = {}) {
},
replicator: { onCloseActiveReplication, runBoundedLocalApplicationActivity },
vault: {
isTargetFile: vi.fn(async () => true),
isTargetFile: vi.fn(options.isTargetFile ?? (async () => true)),
isFileSizeTooLarge: vi.fn(() => false),
isValidPath,
},
},
kvDB: { set: setSnapshot },
kvDB: { set: setSnapshot, get: vi.fn(options.getSnapshot ?? (async () => undefined)) },
localDatabase: {
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
localDatabase: localPhysicalDatabase,
getRaw: vi.fn(async (id: string) => {
if (id === VERSIONING_DOCID) {
if (options.localVersionInfo === undefined) throw { status: 404 };
return options.localVersionInfo;
}
return { _id: id, _rev: "1-test" };
}),
getDBEntryFromMeta,
},
};
@@ -88,6 +107,9 @@ function setup(options: SetupOptions = {}) {
} as never);
return {
getDBEntryFromMeta,
isTargetFile: core.services.vault.isTargetFile,
localPhysicalDatabase,
localDatabase: core.localDatabase,
isReady,
isValidPath,
onCloseActiveReplication,
@@ -98,6 +120,34 @@ function setup(options: SetupOptions = {}) {
}
describe("ReplicateResultProcessor", () => {
it("does not add a permanent application block when snapshot recovery fails", async () => {
const { processor } = setup({
getSnapshot: async () => {
throw new Error("KV unavailable");
},
});
await expect(processor.restoreFromSnapshotOnce()).rejects.toThrow("KV unavailable");
expect(processor.isSuspended).toBe(false);
});
it("restores pending notes without retaining a past feature rejection in KV", async () => {
const { processor, processSynchroniseResult, onCloseActiveReplication } = setup({
databaseId: "same-database",
getSnapshot: async () => ({
databaseId: "same-database",
invalidControlObserved: true,
observedFeatures: ["future-format-v7"],
observedGeneration: 14,
queued: [note("recovered-note")],
processing: [],
}),
});
await processor.restoreFromSnapshotOnce();
expect(processor.isSuspended).toBe(false);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
expect(onCloseActiveReplication).not.toHaveBeenCalled();
});
it.each([
["Windows", isValidFilenameInWidows],
["Android", isValidFilenameInAndroid],
@@ -145,9 +195,9 @@ describe("ReplicateResultProcessor", () => {
});
}
expect(processSynchroniseResult).toHaveBeenCalledTimes(11);
expect(processSynchroniseResult.mock.calls.some(([entry]) =>
(entry as { _id: string })._id === "unrelated-queue"
)).toBe(true);
expect(
processSynchroniseResult.mock.calls.some(([entry]) => (entry as { _id: string })._id === "unrelated-queue")
).toBe(true);
});
it("suspends result application while the application is not ready", () => {
@@ -157,6 +207,35 @@ describe("ReplicateResultProcessor", () => {
expect(isReady).toHaveBeenCalledOnce();
});
it("applies documents held before readiness once the application becomes ready", async () => {
const { isReady, processor, processSynchroniseResult } = setup({ applicationReady: false });
processor.enqueueAll([note("held")]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(processSynchroniseResult).not.toHaveBeenCalled();
expect(processor["_queuedChanges"]).toHaveLength(1);
isReady.mockReturnValue(true);
processor.continueHeldDocuments();
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
expect(processor["_queuedChanges"]).toHaveLength(0);
});
it("keeps an explicit suspension when the application becomes ready", async () => {
const { isReady, processor, processSynchroniseResult } = setup({ applicationReady: false });
processor.suspend();
processor.enqueueAll([note("held")]);
isReady.mockReturnValue(true);
processor.continueHeldDocuments();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(processSynchroniseResult).not.toHaveBeenCalled();
expect(processor["_queuedChanges"]).toHaveLength(1);
processor.resume();
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
});
it("applies results in remediation mode, which never reports readiness", () => {
const { processor } = setup({
applicationReady: false,
@@ -199,10 +278,10 @@ describe("ReplicateResultProcessor", () => {
it("retires active ownership when a newer remote version is observed", async () => {
const { onCloseActiveReplication, processor } = setup();
const versionInfo = {
_id: "versioninfo",
_id: VERSIONING_DOCID,
_rev: "1-test",
type: "versioninfo",
version: VER + 1,
version: REMOTE_FEATURE_GENERATION + 1,
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
processor.enqueueAll([versionInfo]);
@@ -210,6 +289,64 @@ describe("ReplicateResultProcessor", () => {
await vi.waitFor(() => expect(onCloseActiveReplication).toHaveBeenCalledOnce());
});
it("continues applying documents after restoring a legacy local version document", async () => {
const { onCloseActiveReplication, processor, processSynchroniseResult } = setup({
localVersionInfo: {
_id: VERSIONING_DOCID,
type: "versioninfo",
version: 11,
},
});
await processor.restoreFromSnapshotOnce();
processor.enqueueAll([note("legacy-database-note")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
expect(processor.isSuspended).toBe(false);
expect(onCloseActiveReplication).not.toHaveBeenCalled();
});
it("continues when a newly received feature is supported", async () => {
const { onCloseActiveReplication, processor, processSynchroniseResult } = setup();
const versionInfo = {
_id: VERSIONING_DOCID,
_rev: "2-supported",
type: "versioninfo",
version: REMOTE_FEATURE_GENERATION,
used_features: [ENCRYPTED_INTERNAL_METADATA_FEATURE],
} as PouchDB.Core.ExistingDocument<EntryDoc>;
processor.enqueueAll([versionInfo, note("supported-update")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
expect(onCloseActiveReplication).not.toHaveBeenCalled();
});
it("reports unknown feature identifiers and requests Replicator retirement", () => {
const logger = vi.fn();
setGlobalLogFunction(logger);
try {
const { processor, onCloseActiveReplication } = setup();
processor.enqueueAll([
{
_id: VERSIONING_DOCID,
_rev: "1-unknown",
type: "versioninfo",
version: REMOTE_FEATURE_GENERATION,
used_features: ["future-format-v7"],
} as PouchDB.Core.ExistingDocument<EntryDoc>,
]);
expect(onCloseActiveReplication).toHaveBeenCalledOnce();
expect(logger).toHaveBeenCalledWith(
expect.stringContaining("future-format-v7"),
LOG_LEVEL_NOTICE,
undefined
);
} finally {
setGlobalLogFunction(defaultLogger);
}
});
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
const documents = [
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
@@ -1,4 +1,8 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
VERSIONING_DOCID,
type EntryDoc,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
@@ -7,12 +11,27 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REPLICATION_PROGRESS_PRESENTATIONS,
assessRemoteFeatureDocument,
describeRemoteFeatureRejection,
type ReplicatorInstance,
type ReplicationFailureRequest,
} from "@vrtmrz/livesync-commonlib/replication";
import { $msg } from "@/common/translation";
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import type PouchDB from "pouchdb-core";
async function canInterpretCleanupDatabase(db: PouchDB.Database<EntryDoc>): Promise<boolean> {
try {
const assessment = assessRemoteFeatureDocument(await db.get(VERSIONING_DOCID));
if (assessment.status === "supported" || assessment.status === "older-generation") return true;
Logger(`Database cleanup cancelled: ${describeRemoteFeatureRejection(assessment)}`, LOG_LEVEL_NOTICE);
} catch (error) {
Logger("Database cleanup cancelled: feature compatibility could not be checked.", LOG_LEVEL_NOTICE);
Logger(error, LOG_LEVEL_INFO);
}
return false;
}
type CentralCompatibilityRecoveryServices = Pick<
LiveSyncBaseCore["services"],
@@ -60,6 +79,7 @@ export function createCentralCompatibilityRecovery(context: CentralCompatibility
) {
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
await skipIfDuplicated("cleanup", async () => {
if (!(await canInterpretCleanupDatabase(context.getLocalDatabase().localDatabase))) return;
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
const message = `The remote database has been cleaned up.
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { VERSIONING_DOCID, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
import {
@@ -24,6 +24,49 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
describe("central compatibility recovery", () => {
it("does not count chunks for cleanup when local feature requirements are unknown", async () => {
chunkMocks.purgeUnreferencedChunks.mockClear();
const confirmWithMessage = vi.fn(async () => "Dismiss");
const recovery = createCentralCompatibilityRecovery({
confirm: { confirmWithMessage },
getLocalDatabase: () => ({
localDatabase: {
get: vi.fn(async (id: string) => ({
_id: id,
type: "versioninfo",
version: 13,
used_features: ["future-format-v7"],
})),
},
}),
services: { replicator: {} },
} as never);
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
expect(confirmWithMessage).not.toHaveBeenCalled();
});
it("allows cleanup counting for a legacy local version document", async () => {
chunkMocks.purgeUnreferencedChunks.mockClear();
const confirmWithMessage = vi.fn(async () => "Dismiss");
const recovery = createCentralCompatibilityRecovery({
confirm: { confirmWithMessage },
getLocalDatabase: () => ({
localDatabase: {
get: vi.fn(async (id: string) => ({ _id: id, type: "versioninfo", version: 11 })),
},
}),
services: { replicator: {} },
} as never);
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
expect(chunkMocks.purgeUnreferencedChunks).toHaveBeenCalledWith(expect.anything(), true);
expect(confirmWithMessage).toHaveBeenCalledOnce();
});
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
const setting = { customChunkSize: 0 };
const preferredTweakValue = { customChunkSize: 60 };
@@ -292,7 +335,9 @@ describe("central compatibility recovery", () => {
});
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const openOneShotReplication = vi.fn(async () => true);
const remoteDatabase = { close: vi.fn(async () => undefined) };
const remoteDatabase = {
close: vi.fn(async () => undefined),
};
const close = vi.fn(async () => undefined);
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), {
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })),
@@ -303,7 +348,12 @@ describe("central compatibility recovery", () => {
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
task(expectedContext)
);
const localDatabase = { localDatabase: {}, clearCaches: vi.fn() };
const localDatabase = {
localDatabase: {
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 12 })),
},
clearCaches: vi.fn(),
};
const getLocalDatabase = vi.fn(() => localDatabase);
const recovery = createCentralCompatibilityRecovery({
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
@@ -335,7 +385,7 @@ describe("central compatibility recovery", () => {
activityFinished.mock.invocationCallOrder[0]
);
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
expect(getLocalDatabase).toHaveBeenCalledTimes(2);
expect(getLocalDatabase).toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
});
+4
View File
@@ -4,6 +4,7 @@ import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/servic
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { fireAndForget } from "octagonal-wheels/promises";
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { EVENT_APPLICATION_READY } from "@/common/events";
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
import { createOnlineReplicationPreflight, createSecuritySeedPreflight } from "./preflight";
@@ -102,6 +103,9 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
fireAndForget(() => resultProcessor.restoreFromSnapshotOnce());
return Promise.resolve(true);
});
// Commonlib emits this each time it establishes readiness. Documents held until then, such as those restored
// from the snapshot or received during a fetch, continue from here.
services.context.events.onEvent(EVENT_APPLICATION_READY, () => resultProcessor.continueHeldDocuments());
services.appLifecycle.onSettingLoaded.addHandler(initialiseAutomaticReplicationTriggers);
services.replication.parseSynchroniseResult.addHandler((documents) => {
resultProcessor.enqueueAll(documents);
@@ -0,0 +1,171 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { VERSIONING_DOCID, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
import { EVENT_APPLICATION_READY, eventHub } from "@/common/events";
import { useReplicationFeature } from "./index";
type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>;
function receivedNote(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
return {
_id: id,
_rev: "1-received",
path: `${id}.md`,
ctime: 1,
mtime: 2,
size: 1,
children: [],
datatype: "plain",
type: "plain",
eden: {},
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
}
function setup() {
let applicationReady = false;
const settings = {
handleFilenameCaseSensitive: false,
ignoreFiles: "",
maxMTimeForReflectEvents: 0,
suspendParseReplicationResult: false,
syncIgnoreRegEx: "",
syncInternalFiles: false,
syncMaxSizeInMB: 0,
syncOnlyRegEx: "",
useIgnoreFiles: false,
};
const processSynchroniseResult = vi.fn(async () => true);
const settingLoadedHandlers: (() => Promise<boolean>)[] = [];
const context = createServiceContext();
const keyValueDB = {
get: vi.fn(async () => undefined),
set: vi.fn(async () => undefined),
};
const localDatabase = {
getRaw: vi.fn(async (id: string) => {
if (id === VERSIONING_DOCID) throw { status: 404 };
return { _id: id, _rev: "1-received" };
}),
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "received content" })),
};
let parseHandler: ParseHandler | undefined;
const services = {
API: { isMobile: vi.fn(() => false), isOnline: true },
appLifecycle: {
getUnresolvedMessages: { addHandler: vi.fn() },
isReady: () => applicationReady,
isSuspended: vi.fn(() => false),
onSettingLoaded: {
addHandler: vi.fn((handler: () => Promise<boolean>) => settingLoadedHandlers.push(handler)),
},
},
context,
database: { isDatabaseReady: vi.fn(() => true) },
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
keyValueDB: { kvDB: keyValueDB },
localDatabase,
path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
replication: {
databaseQueueCount: { value: 0 },
storageApplyingCount: { value: 0 },
replicationResultCount: { value: 0 },
onBeforeReplicate: { addHandler: vi.fn() },
onPrepareCentralRemoteReplication: { addHandler: vi.fn() },
onReplicationFailed: { addHandler: vi.fn() },
parseSynchroniseResult: {
addHandler: vi.fn((handler: ParseHandler) => {
parseHandler = handler;
}),
},
processOptionalSynchroniseResult: vi.fn(async () => false),
processSynchroniseResult,
processVirtualDocument: vi.fn(async () => false),
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
},
replicator: {
createRemoteResource: vi.fn(async () => ({
read: vi.fn(async () => new Uint8Array([1])),
dispose: vi.fn(),
})),
onBeforeReplicatorPublication: { addHandler: vi.fn() },
onCloseActiveReplication: vi.fn(async () => true),
},
setting: { currentSettings: vi.fn(() => settings) },
tweakValue: {},
vault: {
isFileSizeTooLarge: vi.fn(() => false),
isTargetFile: vi.fn(async () => true),
isValidPath: vi.fn(() => true),
},
};
const core = {
confirm: {},
get localDatabase() {
return localDatabase;
},
rebuilder: {},
services,
};
useReplicationFeature(core as never);
return {
context,
get applicationReady() {
return applicationReady;
},
processSynchroniseResult,
get parseHandler() {
return parseHandler;
},
settingLoadedHandlers,
setApplicationReady(value: boolean) {
applicationReady = value;
},
settings,
};
}
describe("received change readiness composition", () => {
it("applies a queued received document once readiness is established and preserves explicit suspension", async () => {
eventHub.offAll();
const harness = setup();
try {
await harness.settingLoadedHandlers[0]();
await harness.parseHandler!([receivedNote("ready-note")]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.processSynchroniseResult).not.toHaveBeenCalled();
harness.setApplicationReady(true);
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
await vi.waitFor(() => expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(1));
harness.settings.suspendParseReplicationResult = true;
eventHub.emitEvent(EVENT_SETTING_SAVED, harness.settings as never);
harness.setApplicationReady(false);
await harness.parseHandler!([receivedNote("suspended-note")]);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(1);
harness.setApplicationReady(true);
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
harness.context.events.emitEvent(EVENT_APPLICATION_READY);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(1);
harness.settings.suspendParseReplicationResult = false;
eventHub.emitEvent(EVENT_SETTING_SAVED, harness.settings as never);
await vi.waitFor(() => expect(harness.processSynchroniseResult).toHaveBeenCalledTimes(2));
expect(harness.processSynchroniseResult).toHaveBeenLastCalledWith(
expect.objectContaining({ _id: "suspended-note", path: "suspended-note.md" })
);
} finally {
eventHub.offAll();
}
});
});
@@ -1,8 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { VERSIONING_DOCID, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { EVENT_APPLICATION_READY } from "@/common/events";
import { useReplicationFeature } from "./index";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
type BooleanHandler = (showMessage: boolean) => Promise<boolean>;
type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>;
@@ -19,8 +22,14 @@ type SetupOptions = {
};
function setup(options: SetupOptions = {}) {
const defaultLocalDatabase = {
localDatabase: {},
getRaw: vi.fn(async () => {
throw { status: 404 };
}),
};
const {
getLocalDatabase = () => ({}),
getLocalDatabase = () => defaultLocalDatabase,
keyValueDB = {
kvDB: {
get: vi.fn(async () => undefined),
@@ -39,7 +48,7 @@ function setup(options: SetupOptions = {}) {
API: { isMobile: vi.fn(() => false), isOnline: true },
appLifecycle: {
getUnresolvedMessages: { addHandler: vi.fn() },
isReady: true,
isReady: vi.fn(() => true),
isSuspended: vi.fn(() => false),
onSettingLoaded: { addHandler: vi.fn() },
},
@@ -48,6 +57,7 @@ function setup(options: SetupOptions = {}) {
keyValueDB,
path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
replication: {
replicationResultCount: { value: 0 },
onBeforeReplicate: {
addHandler: vi.fn((handler: BooleanHandler, priority = 0) => {
beforeReplicateHandlers.set(priority, handler);
@@ -87,6 +97,7 @@ function setup(options: SetupOptions = {}) {
return {
beforeReplicateHandlers,
centralRemoteHandlers,
context: services.context,
createRemoteResource,
dispose,
get parseHandler() {
@@ -160,15 +171,31 @@ describe("replication serviceFeature composition", () => {
expect(createRemoteResource).toHaveBeenCalledOnce();
});
it("continues held results when Commonlib establishes application readiness", () => {
const continueHeldDocuments = vi
.spyOn(ReplicateResultProcessor.prototype, "continueHeldDocuments")
.mockImplementation(() => undefined);
try {
const { context } = setup();
expect(continueHeldDocuments).not.toHaveBeenCalled();
context.events.emitEvent(EVENT_APPLICATION_READY);
expect(continueHeldDocuments).toHaveBeenCalledOnce();
} finally {
continueHeldDocuments.mockRestore();
}
});
it("requests owner retirement without awaiting the transition from result application", async () => {
const retirement = promiseWithResolvers<boolean>();
const onCloseActiveReplication = vi.fn(() => retirement.promise);
const harness = setup({ onCloseActiveReplication });
const versionInfo = {
_id: "versioninfo",
_id: VERSIONING_DOCID,
_rev: "1-test",
type: "versioninfo",
version: VER + 1,
version: REMOTE_FEATURE_GENERATION + 1,
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
expect(harness.parseHandler).toBeDefined();
@@ -1,5 +1,7 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { registerSetupProtocolHandler, useSetupProtocolFeature } from "./setupProtocol";
import { encodeTimeBoundSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
vi.mock("@/common/types", () => {
return {
@@ -52,6 +54,35 @@ describe("setupObsidian/setupProtocol", () => {
expect(setupManager.decodeQR).not.toHaveBeenCalled();
});
it.each(["ephemeral", "persistent"] as const)(
"reconstructs the exact encrypted %s URI after protocol query decoding",
async (mode) => {
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
const host = {
services: {
API: {
registerProtocolHandler: vi.fn(
(_action: string, handler: (params: Record<string, string>) => Promise<void>) => {
protocolHandler = handler;
}
),
},
},
} as any;
const setupManager = { onUseSetupURI: vi.fn(async () => true), decodeQR: vi.fn() } as any;
registerSetupProtocolHandler(host, vi.fn(), setupManager);
const generated = await encodeTimeBoundSetupURI(DEFAULT_SETTINGS, "protocol-passphrase", { mode });
const decodedQuery = new URL(generated.uri.trim()).searchParams.get("settings");
expect(decodedQuery?.startsWith("%$")).toBe(true);
await protocolHandler!({ settings: decodedQuery! });
const reconstructed = setupManager.onUseSetupURI.mock.calls[0][1] as string;
expect(reconstructed.replace("mock-config://", "obsidian://setuplivesync?settings=")).toBe(
generated.uri.trim()
);
}
);
it("registerSetupProtocolHandler should route settingsQR payload to decodeQR", async () => {
let protocolHandler: ((params: Record<string, string>) => Promise<void>) | undefined;
const host = {
+85 -23
View File
@@ -1,7 +1,12 @@
import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import {
encodeTimeBoundSetupURI,
getTimeBoundSetupURIUsableUntil,
isTimeBoundSetupURIUsableNow,
type TimeBoundSetupURIMode,
} from "@vrtmrz/livesync-commonlib/setup-uri";
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
@@ -16,32 +21,89 @@ export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise<s
);
}
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
host.services.setting.currentSettings(),
encryptingPassphrase,
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
true
);
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
function formatWindowEnd(usableUntil: number): string {
return new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "short",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
}).format(new Date(usableUntil));
}
async function askSetupURIMode(
host: SetupFeatureHost
): Promise<{ mode: TimeBoundSetupURIMode; usableUntil: number | null } | false> {
let usableUntil: number | null = null;
try {
usableUntil = getTimeBoundSetupURIUsableUntil();
} catch {
// Compatible generation remains available when the device clock is invalid.
}
const timeBound = "Time-bound";
const compatible = "Compatible (no time limit)";
const cancel = "Cancel";
const buttons = usableUntil === null ? [compatible, cancel] : [timeBound, compatible, cancel];
const message =
usableUntil === null
? "Time-bound Setup URIs require a valid device clock. Compatible URIs have no time limit and work with older clients."
: `Time-bound Setup URIs can be opened until ${formatWindowEnd(usableUntil)}. This is the end of the current fixed seven-day UTC window, not seven days from now. Compatible URIs have no time limit and work with older clients.`;
const selected = await host.services.UI.confirm.askSelectStringDialogue(message, buttons, {
title: "Setup URI availability",
defaultAction: usableUntil === null ? compatible : timeBound,
});
if (selected === timeBound && usableUntil !== null) return { mode: "ephemeral", usableUntil };
if (selected === compatible) return { mode: "persistent", usableUntil: null };
return false;
}
async function generateAndCopySetupURI(
host: SetupFeatureHost,
log: LogFunction,
removeProperties: (keyof ObsidianLiveSyncSettings)[],
skipDefaultValue: boolean
) {
const passphrase = await askEncryptingPassphrase(host);
if (passphrase === false) return;
while (true) {
const choice = await askSetupURIMode(host);
if (choice === false) return;
let result;
try {
result = await encodeTimeBoundSetupURI(host.services.setting.currentSettings(), passphrase, {
mode: choice.mode,
removeProperties,
skipDefaultValue,
});
} catch (error) {
if (
choice.mode === "ephemeral" &&
error instanceof Error &&
error.message === "Setup URI window changed during generation"
) {
continue;
}
throw error;
}
if (result.usableUntil !== choice.usableUntil || !isTimeBoundSetupURIUsableNow(result.usableUntil)) {
continue;
}
if (await host.services.UI.promptCopyToClipboard("Setup URI", result.uri)) {
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
}
return;
}
}
export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) {
await generateAndCopySetupURI(host, log, stripExtra ? ["pluginSyncExtendedSetting"] : [], true);
}
export async function copySetupURIFull(host: SetupFeatureHost, log: LogFunction) {
const encryptingPassphrase = await askEncryptingPassphrase(host);
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
host.services.setting.currentSettings(),
encryptingPassphrase,
[],
false
);
if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
}
await generateAndCopySetupURI(host, log, [], false);
}
export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) {
@@ -1,119 +1,203 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import {
encodeTimeBoundSetupURI,
getTimeBoundSetupURIUsableUntil,
isTimeBoundSetupURIUsableNow,
} from "@vrtmrz/livesync-commonlib/setup-uri";
import { askEncryptingPassphrase, copySetupURI, copySetupURIFull, useSetupURIFeature } from "./setupUri";
import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => {
return {
encodeSettingsToSetupURI: vi.fn(),
};
});
vi.mock("@vrtmrz/livesync-commonlib/setup-uri", () => ({
encodeTimeBoundSetupURI: vi.fn(),
getTimeBoundSetupURIUsableUntil: vi.fn(),
isTimeBoundSetupURIUsableNow: vi.fn(),
}));
describe("setupObsidian/setupUri", () => {
const usableUntil = Date.parse("2026-10-01T00:00:00Z");
beforeEach(() => {
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValue(usableUntil);
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "obsidian://setup-time-bound ", usableUntil });
vi.mocked(isTimeBoundSetupURIUsableNow).mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.resetAllMocks();
});
it("askEncryptingPassphrase should delegate to confirm.askString", async () => {
const askString = vi.fn(() => "secret");
const host = {
services: {
UI: {
confirm: {
askString,
},
},
},
} as any;
it("uses the existing password prompt", async () => {
const askString = vi.fn(async () => "secret");
const host = { services: { UI: { confirm: { askString } } } } as any;
const result = await askEncryptingPassphrase(host);
expect(result).toBe("secret");
expect(askString).toHaveBeenCalled();
await expect(askEncryptingPassphrase(host)).resolves.toBe("secret");
expect(askString).toHaveBeenCalledWith(
"Encrypt your settings",
"The passphrase to encrypt the setup URI",
"",
true
);
});
it("copySetupURI should return early when user cancels passphrase", async () => {
const promptCopyToClipboard = vi.fn();
const host = {
services: {
setting: {
currentSettings: vi.fn(() => ({ foo: "bar" })),
},
UI: {
confirm: {
askString: vi.fn(() => false),
},
promptCopyToClipboard,
},
},
} as any;
const log = vi.fn();
await copySetupURI(host, log);
expect(encodeSettingsToSetupURI).not.toHaveBeenCalled();
expect(promptCopyToClipboard).not.toHaveBeenCalled();
expect(log).not.toHaveBeenCalled();
});
it("copySetupURI should encode with short mode by default", async () => {
const promptCopyToClipboard = vi.fn(() => true);
it("shows the exact Time-bound end at selection and uses it by default", async () => {
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
const promptCopyToClipboard = vi.fn(async () => true);
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
const host = {
services: {
setting: {
currentSettings: vi.fn(() => currentSettings),
},
setting: { currentSettings: vi.fn(() => currentSettings) },
UI: {
confirm: {
askString: vi.fn(() => "pass"),
},
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
promptCopyToClipboard,
},
},
} as any;
const log = vi.fn();
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://value" as any);
await copySetupURI(host, log);
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(
currentSettings,
"pass",
["pluginSyncExtendedSetting"],
true
expect(askSelectStringDialogue).toHaveBeenCalledWith(
expect.stringContaining("2026"),
["Time-bound", "Compatible (no time limit)", "Cancel"],
{
title: "Setup URI availability",
defaultAction: "Time-bound",
}
);
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://value");
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
mode: "ephemeral",
removeProperties: ["pluginSyncExtendedSetting"],
skipDefaultValue: true,
});
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-time-bound ");
expect(log).toHaveBeenCalled();
});
it("copySetupURIFull should encode with full mode", async () => {
const promptCopyToClipboard = vi.fn(() => true);
it("selects old-reader-compatible output without a time limit and preserves full export settings", async () => {
const promptCopyToClipboard = vi.fn(async () => true);
const currentSettings = { pluginSyncExtendedSetting: true, x: 1 };
const host = {
services: {
setting: {
currentSettings: vi.fn(() => currentSettings),
},
setting: { currentSettings: vi.fn(() => currentSettings) },
UI: {
confirm: {
askString: vi.fn(() => "pass-full"),
askString: vi.fn(async () => "pass"),
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
},
promptCopyToClipboard,
},
},
} as any;
const log = vi.fn();
vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://full" as any);
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({
uri: "obsidian://setup-compatible ",
usableUntil: null,
});
await copySetupURIFull(host, log);
expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(currentSettings, "pass-full", [], false);
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://full");
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith(currentSettings, "pass", {
mode: "persistent",
removeProperties: [],
skipDefaultValue: false,
});
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "obsidian://setup-compatible ");
expect(log).toHaveBeenCalled();
});
it("keeps Compatible available when the clock is invalid", async () => {
vi.mocked(getTimeBoundSetupURIUsableUntil).mockImplementation(() => {
throw new Error("Invalid Setup URI clock");
});
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
const askSelectStringDialogue = vi.fn(async () => "Compatible (no time limit)");
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: {
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
promptCopyToClipboard: vi.fn(async () => false),
},
},
} as any;
await copySetupURI(host, vi.fn());
expect(askSelectStringDialogue).toHaveBeenCalledWith(
expect.stringContaining("valid device clock"),
["Compatible (no time limit)", "Cancel"],
expect.objectContaining({ defaultAction: "Compatible (no time limit)" })
);
});
it("does not generate after password or mode cancellation", async () => {
const askString = vi.fn(async (): Promise<string | false> => false);
const askSelectStringDialogue = vi.fn(async () => "Cancel");
const promptCopyToClipboard = vi.fn();
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: { confirm: { askString, askSelectStringDialogue }, promptCopyToClipboard },
},
} as any;
await copySetupURI(host, vi.fn());
expect(askSelectStringDialogue).not.toHaveBeenCalled();
askString.mockResolvedValueOnce("pass");
await copySetupURI(host, vi.fn());
expect(encodeTimeBoundSetupURI).not.toHaveBeenCalled();
expect(promptCopyToClipboard).not.toHaveBeenCalled();
});
it("keeps an empty passphrase distinct from cancelling the existing prompt", async () => {
vi.mocked(encodeTimeBoundSetupURI).mockResolvedValue({ uri: "compatible", usableUntil: null });
const promptCopyToClipboard = vi.fn(async () => false);
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: {
confirm: {
askString: vi.fn(async () => ""),
askSelectStringDialogue: vi.fn(async () => "Compatible (no time limit)"),
},
promptCopyToClipboard,
},
},
} as any;
await copySetupURI(host, vi.fn());
expect(encodeTimeBoundSetupURI).toHaveBeenCalledWith({}, "", expect.objectContaining({ mode: "persistent" }));
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "compatible");
});
it("asks again if the window changes after selection", async () => {
const nextUntil = usableUntil + 604_800_000;
vi.mocked(getTimeBoundSetupURIUsableUntil).mockReturnValueOnce(usableUntil).mockReturnValueOnce(nextUntil);
vi.mocked(encodeTimeBoundSetupURI)
.mockResolvedValueOnce({ uri: "old-window", usableUntil: nextUntil })
.mockResolvedValueOnce({ uri: "new-window", usableUntil: nextUntil });
const askSelectStringDialogue = vi.fn(async () => "Time-bound");
const promptCopyToClipboard = vi.fn(async () => false);
const host = {
services: {
setting: { currentSettings: vi.fn(() => ({})) },
UI: {
confirm: { askString: vi.fn(async () => "pass"), askSelectStringDialogue },
promptCopyToClipboard,
},
},
} as any;
await copySetupURI(host, vi.fn());
expect(askSelectStringDialogue).toHaveBeenCalledTimes(2);
expect(promptCopyToClipboard).toHaveBeenCalledTimes(1);
expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "new-window");
});
it("useSetupURIFeature should register onLoaded handler that wires commands and event", async () => {
const addHandler = vi.fn();
const addCommand = vi.fn();
+4
View File
@@ -15,6 +15,8 @@ import {
runReviewHarnessVaultRoundTrip,
} from "@/features/ReviewHarness/reviewHarnessVaultFixture";
import type { CompatibilityReviewController } from "./compatibilityReview";
import { runReviewHarnessIdBenchmark } from "@/features/ReviewHarness/reviewHarnessIdBenchmark";
import { createIdBenchmarkOperations } from "@/features/ReviewHarness/reviewHarnessIdBenchmarkRuntime";
async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise<ReviewHarnessScenarioResult> {
const vault = plugin.app.vault;
@@ -58,6 +60,8 @@ export function useReviewHarness(
getCompatibilityPause: () => compatibilityReview.pendingPause,
openCompatibilityReview: () => compatibilityReview.openReview(),
runVaultRoundTrip: () => runVaultRoundTrip(plugin),
runIdBenchmark: async () =>
runReviewHarnessIdBenchmark(await createIdBenchmarkOperations(), activeWindow.performance),
readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY),
writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value),
deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY),