mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-31 08:51:23 +00:00
Compare commits
33 Commits
1.0.0-rc.1-cli
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d33613017 | |||
| b700c631ca | |||
| 00152523db | |||
| fbfb77440d | |||
| 89d4667d95 | |||
| 21dd24682b | |||
| 5c52d69d66 | |||
| a251648e48 | |||
| 57c9726ae5 | |||
| a6977da9e8 | |||
| 397a9b69c8 | |||
| ce573f18a6 | |||
| 0df1f57f3b | |||
| 0518fab62a | |||
| e296708845 | |||
| b6746be73e | |||
| c385bd7ce7 | |||
| b21c3114e5 | |||
| 17fe6c1518 | |||
| d4994fdd05 | |||
| 93bbab4253 | |||
| 8b7d410327 | |||
| 68b6358345 | |||
| 29c26c43b7 | |||
| 72197a5df0 | |||
| 4723771ee0 | |||
| b22c1bd777 | |||
| 8876213127 | |||
| 61ffdde9b5 | |||
| f1e382c6ed | |||
| 2b95766d4f | |||
| c3f2163204 | |||
| c4d6b768da |
@@ -7,10 +7,18 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'aggregator.html'
|
||||
- 'src/**'
|
||||
- 'test/browser-apps/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/deploy-pages.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'aggregator.html'
|
||||
- 'src/**'
|
||||
- 'test/browser-apps/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/deploy-pages.yml'
|
||||
|
||||
permissions:
|
||||
@@ -30,6 +38,29 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.9.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Chromium
|
||||
run: npx playwright@1.62.0 install --with-deps chromium
|
||||
|
||||
- name: Validate browser applications
|
||||
run: npm run test:browser-apps
|
||||
|
||||
- name: Validate browser application interoperability
|
||||
run: npm run test:e2e:browser-apps:interop
|
||||
|
||||
- name: Validate aggregator
|
||||
run: |
|
||||
test -s aggregator.html
|
||||
@@ -41,10 +72,18 @@ jobs:
|
||||
|
||||
- name: Prepare Pages site
|
||||
run: |
|
||||
mkdir -p _site
|
||||
mkdir -p _site/webapp _site/webpeer
|
||||
cp aggregator.html _site/aggregator.html
|
||||
cp -R src/apps/webapp/dist/. _site/webapp/
|
||||
cp -R src/apps/webpeer/dist/. _site/webpeer/
|
||||
test -s _site/webapp/index.html
|
||||
test -s _site/webapp/webapp.html
|
||||
test -s _site/webpeer/index.html
|
||||
touch _site/.nojekyll
|
||||
|
||||
- name: Validate packaged Pages site
|
||||
run: npm run test:browser-apps:pages
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
|
||||
@@ -137,7 +137,8 @@ jobs:
|
||||
echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action."
|
||||
elif [[ "${PRERELEASE}" == "true" ]]; then
|
||||
echo "Publish the draft initially as a pre-release without replacing the latest stable release."
|
||||
echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request."
|
||||
echo "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch."
|
||||
echo "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release."
|
||||
echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate."
|
||||
else
|
||||
echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds."
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
import { writeFileSync } from "fs";
|
||||
import { allMessages } from "../src/common/messages/combinedMessages.dev.ts";
|
||||
import path from "path";
|
||||
|
||||
const { writeFileSync } = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
const currentPath = __dirname;
|
||||
const outDir = path.resolve(currentPath, "../src/common/messages/combinedMessages.prod.ts");
|
||||
|
||||
console.log(`Writing to ${outDir}`);
|
||||
process.stdout.write(`Writing to ${outDir}\n`);
|
||||
writeFileSync(
|
||||
outDir,
|
||||
`export const allMessages: Readonly<Record<string, Readonly<Record<string, string>>>> = ${JSON.stringify(allMessages, null, 4)};`
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { glob } from "tinyglobby";
|
||||
import { parse } from "yaml";
|
||||
import { objectToDotted } from "./messagelib.ts";
|
||||
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/"));
|
||||
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/"));
|
||||
const files = (await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir })).sort();
|
||||
|
||||
function flattenMessages(src: Record<string, unknown>) {
|
||||
function flattenMessages(src: unknown) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(objectToDotted(src))
|
||||
.map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value] as const)
|
||||
@@ -20,9 +20,14 @@ function flattenMessages(src: Record<string, unknown>) {
|
||||
const localeData = new Map<string, Record<string, string>>();
|
||||
for (const file of files) {
|
||||
const segments = file.split(/[/\\]/);
|
||||
const locale = segments[segments.length - 1]!.replace(/\.yaml$/, "");
|
||||
const content = await readFile(file, "utf-8");
|
||||
localeData.set(locale, flattenMessages(parse(content) ?? {}));
|
||||
const localeFilename = segments[segments.length - 1];
|
||||
if (localeFilename === undefined) {
|
||||
throw new Error(`Could not determine the locale name for ${file}`);
|
||||
}
|
||||
const locale = localeFilename.replace(/\.yaml$/, "");
|
||||
const content = await fsPromises.readFile(file, "utf-8");
|
||||
const parsed: unknown = parse(content);
|
||||
localeData.set(locale, flattenMessages(parsed ?? {}));
|
||||
}
|
||||
|
||||
const baseLocale = "en";
|
||||
@@ -55,4 +60,4 @@ const report = Object.fromEntries(
|
||||
})
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../src/common/rosetta";
|
||||
import { allMessages } from "../src/common/messages/combinedMessages.dev.ts";
|
||||
|
||||
import path from "path";
|
||||
const { writeFileSync } = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const thisFileDir = __dirname;
|
||||
const outDir = path.join(thisFileDir, "i18n");
|
||||
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
import { allMessages } from "../src/common/messages/combinedMessages.prod.ts";
|
||||
const __dirname = import.meta.dirname;
|
||||
import path from "path";
|
||||
|
||||
const { writeFileSync } = process.getBuiltinModule("node:fs");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const thisFileDir = __dirname;
|
||||
const outDir = path.resolve(thisFileDir, "../src/common/messagesJson");
|
||||
|
||||
const out = {} as Record<string, { [key: string]: string | undefined }>;
|
||||
|
||||
for (const [key, value] of Object.entries(allMessages)) {
|
||||
//@ts-ignore
|
||||
for (const [lang, langValue] of Object.entries(allMessages[key])) {
|
||||
for (const [lang, langValue] of Object.entries(value)) {
|
||||
if (!out[lang]) out[lang] = {};
|
||||
if (lang in value) {
|
||||
out[lang][key] = langValue as string;
|
||||
} else {
|
||||
if (lang === "def") {
|
||||
out[lang][key] = key;
|
||||
} else {
|
||||
out[lang][key] = undefined;
|
||||
}
|
||||
}
|
||||
out[lang][key] = langValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const url = process.getBuiltinModule("node:url");
|
||||
|
||||
type InspectionError = {
|
||||
check: "current-label" | "local-reference" | "retired-label";
|
||||
@@ -20,7 +20,7 @@ const messageCataloguePath = "src/common/messagesJson/en.json";
|
||||
const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu;
|
||||
|
||||
function repositoryRootFromThisFile(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
return path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
|
||||
}
|
||||
|
||||
function normaliseReferenceTarget(rawTarget: string): string {
|
||||
@@ -48,14 +48,14 @@ async function inspectLocalReferences(
|
||||
const [pathPart] = target.split("#", 1);
|
||||
if (!pathPart) continue;
|
||||
checked++;
|
||||
const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart);
|
||||
const referencedPath = path.resolve(repositoryRoot, path.dirname(documentPath), pathPart);
|
||||
try {
|
||||
await access(referencedPath);
|
||||
await fsPromises.access(referencedPath);
|
||||
} catch {
|
||||
errors.push({
|
||||
check: "local-reference",
|
||||
file: documentPath,
|
||||
detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`,
|
||||
detail: `Missing local reference: ${path.relative(repositoryRoot, referencedPath)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -68,14 +68,13 @@ export async function inspectTroubleshootingDocs(
|
||||
const errors: InspectionError[] = [];
|
||||
const documents = new Map<string, string>();
|
||||
for (const guidePath of guidePaths) {
|
||||
documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8"));
|
||||
documents.set(guidePath, await fsPromises.readFile(path.resolve(repositoryRoot, guidePath), "utf8"));
|
||||
}
|
||||
|
||||
const troubleshooting = documents.get("docs/troubleshooting.md")!;
|
||||
const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const catalogue = JSON.parse(
|
||||
await fsPromises.readFile(path.resolve(repositoryRoot, messageCataloguePath), "utf8")
|
||||
) as Record<string, string>;
|
||||
const requiredMessageKeys = [
|
||||
"TweakMismatchResolve.Action.UseConfigured",
|
||||
"TweakMismatchResolve.Action.UseMine",
|
||||
@@ -132,7 +131,7 @@ async function runCli(): Promise<void> {
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined;
|
||||
const invokedPath = process.argv[1] ? url.pathToFileURL(path.resolve(process.argv[1])).href : undefined;
|
||||
if (invokedPath === import.meta.url) {
|
||||
await runCli();
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,27 +1,31 @@
|
||||
// Convert Application convenient Message Resources (JSON) to Human-Editable format (YAML)
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { stringify } from "yaml";
|
||||
import { glob } from "tinyglobby";
|
||||
import { dottedToObject } from "./messagelib";
|
||||
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
const targetDir = resolve(join(__dirname, "../src/common/messagesJson/"));
|
||||
console.log(`Target directory: ${targetDir}`);
|
||||
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesJson/"));
|
||||
process.stdout.write(`Target directory: ${targetDir}\n`);
|
||||
const files = await glob(`*.json`, { expandDirectories: false, absolute: true, cwd: targetDir });
|
||||
for (const file of files) {
|
||||
const filePath = resolve(file);
|
||||
console.log(`Processing file: ${filePath}`);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const jsonDataSrc = JSON.parse(content);
|
||||
const filePath = path.resolve(file);
|
||||
process.stdout.write(`Processing file: ${filePath}\n`);
|
||||
const content = await fsPromises.readFile(filePath, "utf-8");
|
||||
const jsonDataSrc: unknown = JSON.parse(content);
|
||||
if (typeof jsonDataSrc !== "object" || jsonDataSrc === null || Array.isArray(jsonDataSrc)) {
|
||||
throw new TypeError(`Expected ${filePath} to contain a JSON object`);
|
||||
}
|
||||
const jsonDataD2 = Object.fromEntries(
|
||||
Object.entries(jsonDataSrc).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
);
|
||||
const jsonData = dottedToObject(jsonDataD2);
|
||||
const yamlData = stringify(jsonData, { indent: 2 });
|
||||
const yamlFilePath = filePath.replace(/\.json$/, ".yaml").replace("Json", "YAML");
|
||||
await writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
console.log(`Converted ${filePath} to ${yamlFilePath}`);
|
||||
await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`);
|
||||
}
|
||||
|
||||
// console.dir(files, { depth: 0 });
|
||||
|
||||
+47
-36
@@ -1,38 +1,49 @@
|
||||
export function objectToDotted(obj: any, prefix = ""): Record<string, any> {
|
||||
return Object.entries(obj).reduce(
|
||||
(acc, [key, value]) => {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
Object.assign(acc, objectToDotted(value, newKey));
|
||||
} else {
|
||||
acc[newKey] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
export function dottedToObject(obj: Record<string, any>): Record<string, any> {
|
||||
return Object.entries(obj).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (key.includes(" ")) {
|
||||
// Return as is.
|
||||
return { ...acc, [key]: value }; // Skip keys with spaces
|
||||
}
|
||||
const keys = key.split(".");
|
||||
keys.reduce((nestedAcc, currKey, index) => {
|
||||
if (currKey in nestedAcc && typeof nestedAcc[currKey] !== "object") {
|
||||
nestedAcc[currKey] = { _value: nestedAcc[currKey] }; // Convert to object if not already
|
||||
}
|
||||
if (index === keys.length - 1) {
|
||||
nestedAcc[currKey] = value;
|
||||
} else {
|
||||
nestedAcc[currKey] = nestedAcc[currKey] || {};
|
||||
}
|
||||
return nestedAcc[currKey];
|
||||
}, acc);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
|
||||
export function objectToDotted(obj: unknown, prefix = ""): Record<string, unknown> {
|
||||
if (!isRecord(obj)) {
|
||||
throw new TypeError("Expected a message catalogue object");
|
||||
}
|
||||
const flattened: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (isRecord(value)) {
|
||||
Object.assign(flattened, objectToDotted(value, newKey));
|
||||
} else {
|
||||
flattened[newKey] = value;
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
export function dottedToObject(obj: unknown): Record<string, unknown> {
|
||||
if (!isRecord(obj)) {
|
||||
throw new TypeError("Expected a dotted message catalogue object");
|
||||
}
|
||||
const nestedResult: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (key.includes(" ")) {
|
||||
nestedResult[key] = value;
|
||||
continue;
|
||||
}
|
||||
const keys = key.split(".");
|
||||
let nested = nestedResult;
|
||||
for (const [index, currentKey] of keys.entries()) {
|
||||
if (index === keys.length - 1) {
|
||||
nested[currentKey] = value;
|
||||
continue;
|
||||
}
|
||||
const currentValue = nested[currentKey];
|
||||
if (isRecord(currentValue)) {
|
||||
nested = currentValue;
|
||||
continue;
|
||||
}
|
||||
const replacement = currentValue === undefined || currentValue === null ? {} : { _value: currentValue };
|
||||
nested[currentKey] = replacement;
|
||||
nested = replacement;
|
||||
}
|
||||
}
|
||||
return nestedResult;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dottedToObject, objectToDotted } from "./messagelib";
|
||||
|
||||
describe("message catalogue conversion", () => {
|
||||
it("flattens nested objects while preserving leaf values", () => {
|
||||
expect(
|
||||
objectToDotted({
|
||||
dialogue: {
|
||||
title: "Title",
|
||||
options: ["first", "second"],
|
||||
},
|
||||
"literal key": "Literal",
|
||||
})
|
||||
).toEqual({
|
||||
"dialogue.title": "Title",
|
||||
"dialogue.options": ["first", "second"],
|
||||
"literal key": "Literal",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an existing leaf under _value when a dotted child follows it", () => {
|
||||
expect(
|
||||
dottedToObject({
|
||||
section: "Base value",
|
||||
"section.child": "Child value",
|
||||
"literal key": "Literal",
|
||||
})
|
||||
).toEqual({
|
||||
section: {
|
||||
_value: "Base value",
|
||||
child: "Child value",
|
||||
},
|
||||
"literal key": "Literal",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-object catalogue roots", () => {
|
||||
expect(() => objectToDotted("not an object")).toThrow("Expected a message catalogue object");
|
||||
expect(() => dottedToObject(["not", "an", "object"])).toThrow("Expected a dotted message catalogue object");
|
||||
});
|
||||
});
|
||||
+11
-10
@@ -1,29 +1,30 @@
|
||||
// Convert Human-Editable format (YAML) to Application convenient Message Resources (JSON)
|
||||
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join, resolve } from "path";
|
||||
import { parse } from "yaml";
|
||||
import { glob } from "tinyglobby";
|
||||
import { objectToDotted } from "./messagelib";
|
||||
|
||||
const fsPromises = process.getBuiltinModule("node:fs/promises");
|
||||
const path = process.getBuiltinModule("node:path");
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/"));
|
||||
console.log(`Target directory: ${targetDir}`);
|
||||
const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/"));
|
||||
process.stdout.write(`Target directory: ${targetDir}\n`);
|
||||
const files = await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir });
|
||||
for (const file of files) {
|
||||
const filePath = resolve(file);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const jsonDataSrc = parse(content);
|
||||
const filePath = path.resolve(file);
|
||||
const content = await fsPromises.readFile(filePath, "utf-8");
|
||||
const jsonDataSrc: unknown = parse(content);
|
||||
const jsonDataD2 = objectToDotted(jsonDataSrc);
|
||||
const jsonData = Object.fromEntries(
|
||||
Object.entries(jsonDataD2)
|
||||
.map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value])
|
||||
.map(([key, value]): [string, unknown] => [key.endsWith("._value") ? key.slice(0, -7) : key, value])
|
||||
.sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
|
||||
);
|
||||
const yamlData = JSON.stringify(jsonData, null, 4) + "\n";
|
||||
const yamlFilePath = filePath.replace(/\.yaml$/, ".json").replace("YAML", "Json");
|
||||
await writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
console.log(`Converted ${filePath} to ${yamlFilePath}`);
|
||||
await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8");
|
||||
process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`);
|
||||
}
|
||||
|
||||
// console.dir(files, { depth: 0 });
|
||||
|
||||
@@ -38,6 +38,8 @@ npm run buildDev # Development build (one-time)
|
||||
npm run test:integration # Run CouchDB-backed integration tests
|
||||
npm run test:setup-tools # Check provisioning and Setup URI package contracts
|
||||
npm run test:e2e:cli:p2p # Run canonical P2P validation in Compose
|
||||
npm run test:browser-apps # Build and run the WebApp and WebPeer app-owned Chromium tests
|
||||
npm run test:e2e:browser-apps:interop # Run WebApp → WebPeer → CLI in Compose
|
||||
npm run test:e2e:obsidian:local-suite # Run the real Obsidian local suite
|
||||
```
|
||||
|
||||
@@ -66,7 +68,7 @@ To facilitate development and testing, the build process can automatically copy
|
||||
- If you add a feature that interacts with the remote database (e.g., replication changes, custom changes feed parameters, or custom HTTP queries), you are strongly expected to write an integration test to verify the behaviour against a real CouchDB server.
|
||||
- **Commonlib Tests**: Commonlib owns unit and package tests for shared RPC, storage, replication, and platform contracts. LiveSync CI verifies the exact packed dependency as a downstream consumer.
|
||||
|
||||
Regression tests remain beside the implementation which owns their contract. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
|
||||
Regression tests remain in the suite owned by the implementation under test. Plug-in tests may be co-located with their source, while independent application tests remain under `test/apps/` or `test/browser-apps/` so that they stay outside the Community Review source boundary. Prefix a case or group with `compatibility:` when it protects a persisted input or state which current releases still accept, and with `retirement guard:` when it prevents a removed setting, control, or notification from returning. Remove or replace a compatibility case only when the corresponding input is no longer accepted or an equivalent maintained case preserves the contract. Remove a retirement guard only when another current contract makes the old behaviour unreachable. Do not preserve a disconnected historical test as an executable specification when no maintained runner invokes it; Git history is the reference for retired test infrastructure.
|
||||
|
||||
- **CLI E2E** (`src/apps/cli/testdeno/`): Host-independent consumer workflows. The canonical Compose P2P suite covers ordinary two-peer synchronisation, replacement of the current replicator followed by transfer with the same peer, and explicit relay disconnection followed by paused and resumed reconnection. Its lifecycle entry point is included only in the Docker test build and does not add a public CLI command. Run `npm run test:e2e:cli` for the ordinary suite or `npm run test:e2e:cli:p2p` for P2P validation.
|
||||
- **Self-hosted setup tools** (`utils/couchdb/`, `utils/setup/`, and `utils/flyio/`): Deno contract tests consume the exact locked Commonlib registry package, verify current CouchDB, Object Storage, and random-room P2P Setup URI defaults and remote profiles, and keep CouchDB administration separate from package-owned LiveSync database-version negotiation. `unit-ci` also provisions a real temporary CouchDB database and verifies its version document against the installed Commonlib package. Run `npm run test:setup-tools` for the local contract gate.
|
||||
@@ -85,9 +87,11 @@ Regression tests remain beside the implementation which owns their contract. Pre
|
||||
|
||||
- **Test Structure**:
|
||||
- `test/e2e-obsidian/` - Real Obsidian E2E scripts for local verification
|
||||
- `test/apps/webapp/` and `test/apps/webpeer/` - app-owned unit tests outside the Community Review source boundary
|
||||
- `test/browser-apps/webapp/` and `test/browser-apps/webpeer/` - app-owned Deno and Chromium tests outside the Community Review source boundary
|
||||
- `test/browser-apps/` - Compose-owned WebApp → WebPeer → CLI P2P interoperability and shared browser-test support
|
||||
- co-located `*.unit.spec.ts` files - Node-based unit tests
|
||||
- co-located `*.integration.spec.ts` files - service-backed integration tests
|
||||
- `src/apps/webapp/obsidianMock.ts` - Webapp-only Obsidian compatibility adapter; it is not an E2E Harness
|
||||
|
||||
### Import Path Normalisation
|
||||
|
||||
@@ -144,7 +148,7 @@ Hence, the new feature should be implemented as follows:
|
||||
- **LiveSyncLocalDB** (`@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB`): Local PouchDB database wrapper
|
||||
- **Replicators** (`@vrtmrz/livesync-commonlib/compat/replication/*`): CouchDB, Journal, and P2P synchronisation engines
|
||||
- **Service Hub** (`src/modules/services/`): Central service registry using dependency injection
|
||||
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, Webapp, WebPeer, and external tools
|
||||
- **Common Library** (`@vrtmrz/livesync-commonlib`): Platform-independent synchronisation logic, shared with the CLI, WebApp, WebPeer, and external tools
|
||||
|
||||
Commonlib owns the P2P replicator and Trystero transport lifecycle. Host commands, event handlers, and views must retain the Commonlib service-feature result and resolve its current `replicator` at the point of use. They must not snapshot an instance which can be replaced when settings or the local database change, close Trystero-owned raw peers, or install another Trystero transport generation at the application root.
|
||||
|
||||
@@ -246,6 +250,7 @@ export class ModuleExample extends AbstractObsidianModule {
|
||||
- A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag.
|
||||
- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action.
|
||||
- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation.
|
||||
- After a staged stable version passes BRAT validation, merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. Promote the GitHub Release only after the default branch contains the exact stable metadata; publish stable CLI tags through a separate maintainer gate.
|
||||
- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version.
|
||||
|
||||
## Release Notes
|
||||
@@ -271,7 +276,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request.
|
||||
- Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release.
|
||||
- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action.
|
||||
- After a stable version passes, remove its GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. Only then mark the stable release pull request ready and merge it into the selected base branch with a merge commit.
|
||||
- After a stable version passes, mark its release pull request ready and merge the exact release commit into the selected base branch with a merge commit. Integrate that exact commit through the reviewed branch chain into the repository's default branch. Only after the default branch contains the matching stable metadata, remove the GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate.
|
||||
- If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show <tag>:updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history.
|
||||
- Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action.
|
||||
- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`.
|
||||
@@ -302,7 +307,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel
|
||||
8. Update the PR state message to describe the published pre-release and state that merging remains on hold.
|
||||
9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario.
|
||||
10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action.
|
||||
11. After a stable version passes, remove its pre-release designation, make the exact release the latest stable release, publish the stable CLI tags through a separate maintainer gate if selected, then mark the release PR ready and merge it into the selected base branch.
|
||||
11. After a stable version passes, mark the release PR ready and merge the exact release commit into the selected base branch. Integrate that commit through the reviewed branch chain into the repository's default branch. Once the default branch contains the matching stable metadata, remove the pre-release designation, make the exact release the latest stable release, and publish stable CLI tags through a separate maintainer gate if selected.
|
||||
12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries.
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
@@ -37,9 +37,9 @@ The Obsidian host injects the screen wake-lock manager from the `octagonal-wheel
|
||||
|
||||
Finite replication enters both counts only after readiness checks have succeeded and leaves them after `openReplication(..., continuous: false, ...)` settles. A successful completion has reached the latest sequence in that operation's scope and is therefore an authoritative quiescence boundary for chunk retrieval. A failed operation does not prove latest state, but can no longer deliver documents from that attempt. Failure handling runs afterwards so a mismatch or recovery dialogue does not retain the activity. This includes the direct start-up synchronisation path as well as manual, event-driven, and periodic calls through `ReplicationService`. The unbounded continuous channel does not enter either boundary, but its finite initial pull-only catch-up does; the one-shot parameter fallback chain remains inside that boundary.
|
||||
|
||||
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. A mobile device can have only a short opportunity to obtain remote data, while applying a large downloaded batch to the Vault is durable, offline-capable work which can continue or resume later. The replication-result queue and its recovery snapshot therefore remain outside `boundedRemoteActivityCount`. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application.
|
||||
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application. The Obsidian host tracks that queue through a separate `boundedLocalApplicationActivityCount`, so local Vault writes do not increment either remote-activity count or keep the `📲` indicator visible.
|
||||
|
||||
This separation also keeps the activity indicators truthful: `📲` describes a finite remote operation and must not remain active solely because local Vault writes are pending. The existing replication-result count continues to describe that local queue. If a future feature offers screen-awake protection while applying downloaded documents, it must use a separately typed local-application activity or power-policy boundary, preserve the current behaviour by default, and avoid incrementing either remote-activity count.
|
||||
Applying downloaded document changes enters one local-application boundary from the first queued document until the queue and its in-progress set are both empty. The final empty recovery snapshot is attempted before the boundary settles; snapshot failure is logged and still releases the boundary. Additional batches share the existing boundary. Suspending replication-result processing releases it, and resuming reacquires it while work remains. The same host activity runner supplies best-effort Wake Lock protection, while the visibility lifecycle waits for both remote and local bounded activity to finish.
|
||||
|
||||
Manual P2P commands which bypass `ReplicationService` enter the broad boundary. Direct P2P pull and push entry points are therefore both protected as finite remote work, covering the Obsidian panes, CLI, and Webapp. A pull or bidirectional synchronisation also enters the narrower finite-replication boundary because it can place documents in the local database. A push-only request remains broad-only: it cannot satisfy a local missing-chunk read and must not present itself as a delivery source. Automatic synchronisation on peer discovery, a pull requested by a remote peer, and a watched pull following a peer progress notification enter both boundaries because each can deliver local documents. A normal P2P peer-selection dialogue represents one broad finite session: it remains inside the boundary while waiting for a peer and while the person may perform repeated synchronisations, then settles when the dialogue closes and any in-flight synchronisation has finished. Closing without synchronising returns a failed result and releases the boundary. The 'Start Sync & Close' action completes its synchronisation before closing. This deliberately protects peer discovery and selection, because display sleep can interrupt discovery or connection establishment and require the person to start detection again. It may therefore retain a Wake Lock longer than the network transfer alone. A transfer performed inside that session temporarily adds a nested activity; the count remains a logical-operation count rather than a connection total.
|
||||
|
||||
@@ -76,6 +76,8 @@ P2P does not yet contribute to the physical-request count because it does not ha
|
||||
|
||||
The platform activity runner remains injected. Common library and headless consumers can omit it while retaining the same bounded activity count and operation semantics.
|
||||
|
||||
The Obsidian-specific `ObsidianReplicatorService` owns `boundedLocalApplicationActivityCount` and reuses the injected activity runner. It is deliberately absent from the common service contract: CLI and Webapp processing continue directly, while the Obsidian host uses the count for Wake Lock and visibility-lifecycle policy without changing remote-operation reporting.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not count continuous replication as a bounded activity.
|
||||
@@ -85,11 +87,11 @@ The platform activity runner remains injected. Common library and headless consu
|
||||
- Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action.
|
||||
- Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work.
|
||||
- Do not broaden `keepReplicationActiveInBackground`; it remains an opt-in desktop policy for continuous and periodic operation after finite work has ended.
|
||||
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in this boundary. They are offline-capable and require a separate decision if activity reporting or power policy is added later.
|
||||
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in either remote-activity count. The queue uses its separate local-application boundary.
|
||||
|
||||
## Verification
|
||||
|
||||
Before changing the transfer/application separation, add a deterministic regression scenario which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, persists the queued state, and resumes Vault application after suspension or restart. An optional local-application Wake Lock feature requires its own enabled and disabled cases; existing remote-operation E2E is not evidence for that separate policy.
|
||||
Before changing the transfer/application separation, keep deterministic coverage which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, and retains the separate local-application boundary until Vault application and the final recovery snapshot settle.
|
||||
|
||||
Unit tests cover:
|
||||
|
||||
@@ -106,6 +108,8 @@ Unit tests cover:
|
||||
- remote chunk fetching remaining inside the shared boundary from synchronous queue acceptance through local persistence and terminal notification;
|
||||
- missing-chunk waiters rechecking local storage when observed per-identifier claims and finite replication have settled;
|
||||
- the finite-replication count excluding other bounded work;
|
||||
- replicated document application sharing one local boundary, settling after the final recovery snapshot, and releasing around processing suspension;
|
||||
- local application activity leaving both remote-activity counts unchanged;
|
||||
- standard, fast, remote, and combined rebuild activity boundaries;
|
||||
- Rebuilder-owned confirmation and completion dialogues remaining outside rebuild activity;
|
||||
- fallback from fast fetch avoiding a nested activity boundary;
|
||||
@@ -125,5 +129,5 @@ The exact Fancy Kit screen wake-lock behaviour is covered by its package and Har
|
||||
- One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals.
|
||||
- Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation.
|
||||
- Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild.
|
||||
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, allowing transfer and offline application to follow different mobile lifetimes without presenting local writes as communication.
|
||||
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, while a separate local-application boundary retains best-effort Wake Lock and lifecycle protection without presenting local writes as communication.
|
||||
- Users can now distinguish the lifetime of a finite remote operation from approximate request activity within it.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# 1.0 preview release history
|
||||
|
||||
This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md).
|
||||
|
||||
The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted.
|
||||
|
||||
## 1.0.0-rc.1
|
||||
|
||||
27th July, 2026
|
||||
|
||||
The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here).
|
||||
|
||||
### Important
|
||||
|
||||
- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published.
|
||||
- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication.
|
||||
|
||||
### CLI and release validation
|
||||
|
||||
- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation.
|
||||
- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout.
|
||||
- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow.
|
||||
- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs.
|
||||
|
||||
### Testing
|
||||
|
||||
- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency.
|
||||
- The same scenario completed through the rebuilt non-root Docker image.
|
||||
- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation.
|
||||
|
||||
## 1.0.0-beta.5
|
||||
|
||||
26th July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain.
|
||||
- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent.
|
||||
|
||||
### Testing
|
||||
|
||||
- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions.
|
||||
|
||||
## 1.0.0-beta.4
|
||||
|
||||
25th July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation.
|
||||
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**.
|
||||
- Text in setup and review dialogues can now be selected for copying or translation.
|
||||
- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device.
|
||||
- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts.
|
||||
- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation.
|
||||
- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip.
|
||||
|
||||
## 1.0.0-beta.3
|
||||
|
||||
24th July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices.
|
||||
- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits.
|
||||
- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`.
|
||||
- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation.
|
||||
- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings.
|
||||
- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues.
|
||||
|
||||
## 1.0.0-beta.2
|
||||
|
||||
23rd July, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages.
|
||||
|
||||
## 1.0.0-beta.1
|
||||
|
||||
22nd July, 2026
|
||||
|
||||
### Important
|
||||
|
||||
- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers.
|
||||
- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner.
|
||||
|
||||
### Testing
|
||||
|
||||
- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active.
|
||||
|
||||
## 1.0.0-beta.0
|
||||
|
||||
22nd July, 2026
|
||||
|
||||
### Important
|
||||
|
||||
- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation.
|
||||
- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully.
|
||||
|
||||
### Improved
|
||||
|
||||
- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically.
|
||||
- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins.
|
||||
- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow.
|
||||
- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification.
|
||||
- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive.
|
||||
- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room.
|
||||
- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555.
|
||||
- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer.
|
||||
- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request.
|
||||
- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update.
|
||||
- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected.
|
||||
|
||||
### Security
|
||||
|
||||
- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository.
|
||||
|
||||
### Testing
|
||||
|
||||
- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.0-rc.1",
|
||||
"version": "1.0.2",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+31
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0-rc.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0-rc.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -22,15 +22,17 @@
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
@@ -4763,6 +4765,15 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/browser-ui-kit": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/browser-ui-kit/-/browser-ui-kit-0.1.0.tgz",
|
||||
"integrity": "sha512-UlMPRZS3lLFcuEKBaAPcSkIY0we3+kj32GHLcOAfxmooy8XaJWh9/VP5MCC+lo06GrZ6graE3vwmAiFsAHwgng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vrtmrz/ui-interactions": "0.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
|
||||
@@ -4827,12 +4838,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/obsidian-plugin-kit": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.2.tgz",
|
||||
"integrity": "sha512-LNNV8QCaN6FvRA+of96GiqI8atAbnMKQvLbqpi3nsepEe6tN8SmXO3P7pqBl3axNXEHhQfU5ggj5HAq9GJPgwQ==",
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-plugin-kit/-/obsidian-plugin-kit-0.1.3.tgz",
|
||||
"integrity": "sha512-6fsKdhFZtBv6FXlZHtSmpqwROohFzDmres6q08nr2xYGVeh2ooBGU3zJS94WN/tOjDT+wa/Vr3yE42wmI0pIZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vrtmrz/ui-interactions": "0.1.1"
|
||||
"@vrtmrz/ui-interactions": "0.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"obsidian": ">=1.8.7"
|
||||
@@ -4853,9 +4864,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/ui-interactions": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.1.tgz",
|
||||
"integrity": "sha512-XpO5fQzC7jyOW3xVF7YtFHI7X1BPQ7hJW56uw8atx8mDR7C9ejG2YSn0XcZ1H5FOCPgJbmkh/FLQRKLqSK8jkw==",
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/ui-interactions/-/ui-interactions-0.1.2.tgz",
|
||||
"integrity": "sha512-njT2BSFh57imwGDxWXJOcGbZOYFVNGmeXMrS7/RUdBoAeOjWzJB9TxI7WcR1sgQIXKh8TE9hqALU8lue5b8dRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@wdio/config": {
|
||||
@@ -11521,9 +11532,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/octagonal-wheels": {
|
||||
"version": "0.1.51",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.51.tgz",
|
||||
"integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==",
|
||||
"version": "0.1.52",
|
||||
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz",
|
||||
"integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"idb": "^8.0.3"
|
||||
@@ -15913,11 +15924,11 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.0-rc.1-cli",
|
||||
"version": "1.0.2-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
@@ -15938,9 +15949,9 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.0-rc.1-webapp",
|
||||
"version": "1.0.2-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
@@ -15950,9 +15961,9 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.0-rc.1-webpeer",
|
||||
"version": "1.0.2-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
+14
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.0-rc.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -12,6 +12,7 @@
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint --cache --cache-strategy content --concurrency off src",
|
||||
"lint:community": "eslint --config eslint.community.config.mjs --concurrency off src",
|
||||
"lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools",
|
||||
"svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
|
||||
"tsc-check": "tsc --noEmit",
|
||||
"tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json",
|
||||
@@ -22,13 +23,19 @@
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
||||
"precheck:compatibility": "npm run build",
|
||||
"check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15",
|
||||
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility",
|
||||
"check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility",
|
||||
"i18n:bake": "npm run i18n:yaml2json && npm run i18n:bakejson && npm run i18n:format",
|
||||
"i18n:bakejson": "tsx _tools/bakei18n.ts",
|
||||
"i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",
|
||||
"i18n:json2yaml": "tsx _tools/json2yaml.ts",
|
||||
"i18n:yaml2json": "tsx _tools/yaml2json.ts",
|
||||
"test:unit": "vitest run --config vitest.config.unit.ts",
|
||||
"build:browser-apps": "npm run build --workspace livesync-webapp --workspace webpeer",
|
||||
"test:browser-apps": "npm run test:browser --workspace livesync-webapp && npm run test:browser --workspace webpeer",
|
||||
"test:browser-apps:pages": "deno test -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/pages/browser-smoke.test.ts",
|
||||
"test:e2e:browser-apps": "npm run test:browser-apps",
|
||||
"pretest:e2e:browser-apps:interop": "npm run build:browser-apps && npm run build --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:browser-apps:interop": "deno run -A --no-check --frozen --config test/browser-apps/deno.json --lock test/browser-apps/deno.lock test/browser-apps/run-compose-interop.ts",
|
||||
"inspect:troubleshooting": "tsx _tools/inspect-troubleshooting-docs.ts",
|
||||
"test:setup-tools": "bash utils/flyio/setenv.test.sh && deno test -A --config=utils/flyio/deno.jsonc --frozen --lock=utils/flyio/deno.lock utils/livesync-commonlib-version.test.ts utils/couchdb/provision.test.ts utils/setup/generate_setup_uri.test.ts utils/flyio/generate_setupuri.test.ts",
|
||||
"test:contract:contexts": "vitest run --config vitest.config.unit.ts src/modules/services/ObsidianServiceContext.unit.spec.ts src/apps/cli/services/NodeServiceContext.unit.spec.ts src/apps/browser/createLiveSyncBrowserServiceHub.unit.spec.ts",
|
||||
@@ -51,6 +58,7 @@
|
||||
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
|
||||
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
|
||||
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
|
||||
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
|
||||
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
|
||||
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
@@ -165,15 +173,17 @@
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.2",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.2.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"obsidian": "^1.13.1",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
},
|
||||
|
||||
@@ -1,136 +1,39 @@
|
||||
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
|
||||
import MessageBox from "./ui/MessageBox.svelte";
|
||||
import TextInputBox from "./ui/TextInputBox.svelte";
|
||||
|
||||
import { mount } from "svelte";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { _activeDocument, compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { BrowserUiNotifications, createBrowserUi } from "@vrtmrz/browser-ui-kit";
|
||||
|
||||
function displayMessageBox<T, U extends string[]>(
|
||||
message: string,
|
||||
buttons: U,
|
||||
title: string,
|
||||
commit: (ret: U[number]) => T,
|
||||
actionLayout: ConfirmActionLayout = "vertical"
|
||||
): Promise<T> {
|
||||
const el = createNativeElement(_activeDocument, "div");
|
||||
const p = promiseWithResolvers<T>();
|
||||
mount(MessageBox, {
|
||||
target: el,
|
||||
props: {
|
||||
message,
|
||||
buttons: buttons as string[],
|
||||
title: title,
|
||||
actionLayout,
|
||||
commit: (action: U[number]) => {
|
||||
const ret = commit(action);
|
||||
p.resolve(ret);
|
||||
},
|
||||
},
|
||||
});
|
||||
_activeDocument.body.appendChild(el);
|
||||
void p.promise.finally(() => {
|
||||
el.remove();
|
||||
});
|
||||
return p.promise;
|
||||
}
|
||||
function promptForInput(
|
||||
title: string,
|
||||
key: string,
|
||||
placeholder: string,
|
||||
isPassword?: boolean
|
||||
): Promise<string | false> {
|
||||
const el = createNativeElement(_activeDocument, "div");
|
||||
const p = promiseWithResolvers<string | false>();
|
||||
mount(TextInputBox, {
|
||||
target: el,
|
||||
props: {
|
||||
title,
|
||||
message: key,
|
||||
placeholder,
|
||||
isPassword,
|
||||
commit: (text: string | false) => {
|
||||
p.resolve(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
_activeDocument.body.appendChild(el);
|
||||
void p.promise.finally(() => {
|
||||
el.remove();
|
||||
});
|
||||
return p.promise;
|
||||
}
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { renderMessageMarkdownInto } from "./ui/renderMessageMarkdown";
|
||||
import { UiInteractionsConfirm } from "./UiInteractionsConfirm";
|
||||
|
||||
/**
|
||||
* Compatibility facade consumed by Commonlib while browser presentation is
|
||||
* implemented through Fancy Kit's neutral `UiInteractions` contract.
|
||||
*/
|
||||
export class BrowserConfirm<T extends ServiceContext> extends UiInteractionsConfirm {
|
||||
readonly context: T;
|
||||
|
||||
export class BrowserConfirm<T extends ServiceContext> implements Confirm {
|
||||
_context: T;
|
||||
constructor(context: T) {
|
||||
this._context = context;
|
||||
}
|
||||
askYesNo(message: string): Promise<"yes" | "no"> {
|
||||
return displayMessageBox(message, ["Yes", "No"] as const, "Confirm", (action) =>
|
||||
action == "Yes" ? "yes" : "no"
|
||||
);
|
||||
}
|
||||
askString(title: string, key: string, placeholder: string, isPassword?: boolean): Promise<string | false> {
|
||||
return promptForInput(title, key, placeholder, isPassword);
|
||||
}
|
||||
askYesNoDialog(
|
||||
message: string,
|
||||
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number }
|
||||
): Promise<"yes" | "no"> {
|
||||
return displayMessageBox(message, ["Yes", "No"] as const, opt.title ?? "Confirm", (action) =>
|
||||
action == "Yes" ? "yes" : "no"
|
||||
);
|
||||
}
|
||||
askSelectString(message: string, items: string[]): Promise<string> {
|
||||
return displayMessageBox(message, [...items] as const, "Confirm", (action) => action);
|
||||
}
|
||||
askSelectStringDialogue<T extends readonly string[]>(
|
||||
message: string,
|
||||
buttons: T,
|
||||
opt: { title?: string; defaultAction: T[number]; timeout?: number }
|
||||
): Promise<T[number] | false> {
|
||||
return displayMessageBox(message, [...buttons] as const, opt.title ?? "Confirm", (action) => action);
|
||||
}
|
||||
askInPopup(
|
||||
key: string,
|
||||
dialogText: string,
|
||||
anchorCallback: (anchor: HTMLAnchorElement) => void,
|
||||
durationMs: number = 20000
|
||||
): void {
|
||||
const existing = _activeDocument.querySelector(`[data-livesync-popup="${CSS.escape(key)}"]`);
|
||||
existing?.remove();
|
||||
|
||||
const notice = createNativeElement(_activeDocument, "div");
|
||||
notice.className = "livesync-browser-notice";
|
||||
notice.dataset.livesyncPopup = key;
|
||||
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
|
||||
notice.append(beforeText);
|
||||
const anchor = createNativeElement(_activeDocument, "a");
|
||||
anchor.href = "#";
|
||||
anchorCallback(anchor);
|
||||
anchor.addEventListener("click", () => notice.remove());
|
||||
notice.append(anchor, afterText ?? "");
|
||||
_activeDocument.body.appendChild(notice);
|
||||
compatGlobal.setTimeout(() => notice.remove(), durationMs);
|
||||
}
|
||||
confirmWithMessage(
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: string[],
|
||||
defaultAction: (typeof buttons)[number],
|
||||
timeout?: number,
|
||||
actionLayout?: ConfirmActionLayout
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
return displayMessageBox(
|
||||
contentMd,
|
||||
[...buttons] as const,
|
||||
title ?? "Confirm",
|
||||
(action) => action,
|
||||
actionLayout
|
||||
);
|
||||
const dialogueController = new AbortController();
|
||||
const notifications = new BrowserUiNotifications({
|
||||
document: _activeDocument,
|
||||
});
|
||||
super({
|
||||
ui: createBrowserUi({
|
||||
document: _activeDocument,
|
||||
signal: dialogueController.signal,
|
||||
renderMarkdown: ({ container, markdown }) => {
|
||||
renderMessageMarkdownInto(container, markdown);
|
||||
},
|
||||
}),
|
||||
notifications,
|
||||
createActionAnchor: () => createNativeElement(_activeDocument, "a"),
|
||||
});
|
||||
this.context = context;
|
||||
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
|
||||
dialogueController.abort();
|
||||
notifications.dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
KeyValueDatabase,
|
||||
KeyValueDatabaseFactory,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { deleteDB, openDB, type IDBPDatabase } from "idb";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
|
||||
/** Creates an application-owned IndexedDB key-value factory for browser runtimes. */
|
||||
export function createBrowserKeyValueDatabaseFactory(): KeyValueDatabaseFactory {
|
||||
const cache = new Map<string, BrowserKeyValueDatabase>();
|
||||
|
||||
return async (databaseKey) =>
|
||||
await serialized(`OpenBrowserKeyValueDatabase-${databaseKey}`, async () => {
|
||||
const cached = cache.get(databaseKey);
|
||||
if (cached && !cached.isDestroyed) {
|
||||
return cached;
|
||||
}
|
||||
if (cached) {
|
||||
await cached.ensuredDestroyed;
|
||||
cache.delete(databaseKey);
|
||||
}
|
||||
|
||||
const database = new BrowserKeyValueDatabase(databaseKey);
|
||||
await database.getIsReady();
|
||||
cache.set(databaseKey, database);
|
||||
return database;
|
||||
});
|
||||
}
|
||||
|
||||
class BrowserKeyValueDatabase implements KeyValueDatabase {
|
||||
private databasePromise?: Promise<IDBPDatabase<unknown>>;
|
||||
private destroyed = false;
|
||||
private destroyedPromise?: Promise<void>;
|
||||
|
||||
constructor(private readonly databaseKey: string) {}
|
||||
|
||||
get isDestroyed(): boolean {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
get ensuredDestroyed(): Promise<void> {
|
||||
return this.destroyedPromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async getIsReady(): Promise<boolean> {
|
||||
await this.ensureDatabase();
|
||||
return !this.destroyed;
|
||||
}
|
||||
|
||||
private ensureDatabase(): Promise<IDBPDatabase<unknown>> {
|
||||
if (this.destroyed) {
|
||||
throw new Error("Database is destroyed");
|
||||
}
|
||||
this.databasePromise ??= openDB(this.databaseKey, undefined, {
|
||||
upgrade: (database) => {
|
||||
if (!database.objectStoreNames.contains(this.databaseKey)) {
|
||||
database.createObjectStore(this.databaseKey);
|
||||
}
|
||||
},
|
||||
blocking: () => {
|
||||
void this.closeDatabase();
|
||||
},
|
||||
terminated: () => {
|
||||
this.databasePromise = undefined;
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
this.databasePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.databasePromise;
|
||||
}
|
||||
|
||||
private get database(): Promise<IDBPDatabase<unknown>> {
|
||||
return this.ensureDatabase();
|
||||
}
|
||||
|
||||
private async closeDatabase(): Promise<void> {
|
||||
const databasePromise = this.databasePromise;
|
||||
this.databasePromise = undefined;
|
||||
if (databasePromise) {
|
||||
(await databasePromise).close();
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: IDBValidKey): Promise<T> {
|
||||
return (await (await this.database).get(this.databaseKey, key)) as T;
|
||||
}
|
||||
|
||||
async set<T>(key: IDBValidKey, value: T): Promise<IDBValidKey> {
|
||||
await (await this.database).put(this.databaseKey, value, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
async del(key: IDBValidKey): Promise<void> {
|
||||
await (await this.database).delete(this.databaseKey, key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await (await this.database).clear(this.databaseKey);
|
||||
}
|
||||
|
||||
async keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<IDBValidKey[]> {
|
||||
return await (await this.database).getAllKeys(this.databaseKey, query, count);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.closeDatabase();
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
if (this.destroyedPromise) {
|
||||
await this.destroyedPromise;
|
||||
return;
|
||||
}
|
||||
this.destroyed = true;
|
||||
this.destroyedPromise = (async () => {
|
||||
await this.closeDatabase();
|
||||
await deleteDB(this.databaseKey);
|
||||
})();
|
||||
await this.destroyedPromise;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
|
||||
|
||||
interface Props {
|
||||
host: P2PReplicatorPaneHost;
|
||||
}
|
||||
|
||||
let { host }: Props = $props();
|
||||
const currentSettings = () => host.services.setting.currentSettings() as P2PSyncSetting;
|
||||
const initialSettings = currentSettings();
|
||||
|
||||
let savedTurnServers = $state(initialSettings.P2P_turnServers);
|
||||
let savedTurnUsername = $state(initialSettings.P2P_turnUsername);
|
||||
let savedTurnCredential = $state(initialSettings.P2P_turnCredential);
|
||||
let turnServers = $state(initialSettings.P2P_turnServers);
|
||||
let turnUsername = $state(initialSettings.P2P_turnUsername);
|
||||
let turnCredential = $state(initialSettings.P2P_turnCredential);
|
||||
|
||||
const isTurnServersModified = $derived(turnServers !== savedTurnServers);
|
||||
const isTurnUsernameModified = $derived(turnUsername !== savedTurnUsername);
|
||||
const isTurnCredentialModified = $derived(turnCredential !== savedTurnCredential);
|
||||
const isModified = $derived(
|
||||
isTurnServersModified || isTurnUsernameModified || isTurnCredentialModified
|
||||
);
|
||||
|
||||
function loadSettings(settings: P2PSyncSetting): void {
|
||||
savedTurnServers = settings.P2P_turnServers;
|
||||
savedTurnUsername = settings.P2P_turnUsername;
|
||||
savedTurnCredential = settings.P2P_turnCredential;
|
||||
turnServers = savedTurnServers;
|
||||
turnUsername = savedTurnUsername;
|
||||
turnCredential = savedTurnCredential;
|
||||
}
|
||||
|
||||
onMount(() =>
|
||||
host.services.context.events.onEvent("setting-saved", (settings) => {
|
||||
loadSettings(settings as P2PSyncSetting);
|
||||
})
|
||||
);
|
||||
|
||||
async function save(): Promise<void> {
|
||||
await host.services.setting.applyPartial(
|
||||
{
|
||||
P2P_turnServers: turnServers,
|
||||
P2P_turnUsername: turnUsername,
|
||||
P2P_turnCredential: turnCredential,
|
||||
},
|
||||
true
|
||||
);
|
||||
loadSettings(currentSettings());
|
||||
}
|
||||
|
||||
function revert(): void {
|
||||
turnServers = savedTurnServers;
|
||||
turnUsername = savedTurnUsername;
|
||||
turnCredential = savedTurnCredential;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="browser-p2p-transport-settings">
|
||||
<details>
|
||||
<summary>Optional TURN server settings</summary>
|
||||
<p>
|
||||
Configure TURN only when a direct peer-to-peer connection cannot be established.
|
||||
</p>
|
||||
<label class:is-dirty={isTurnServersModified}>
|
||||
<span>TURN Server URLs (comma-separated)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="turn:turn.example.com:3478"
|
||||
bind:value={turnServers}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
</label>
|
||||
<label class:is-dirty={isTurnUsernameModified}>
|
||||
<span>TURN Username</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter TURN username"
|
||||
bind:value={turnUsername}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label class:is-dirty={isTurnCredentialModified}>
|
||||
<span>TURN Credential</span>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter TURN credential"
|
||||
bind:value={turnCredential}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="button mod-cta" disabled={!isModified} onclick={save}>
|
||||
Save TURN settings
|
||||
</button>
|
||||
<button type="button" class="button" disabled={!isModified} onclick={revert}>
|
||||
Revert TURN settings
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.browser-p2p-transport-settings {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
label.is-dirty {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
type ComponentHasResult,
|
||||
SvelteDialogManagerBase,
|
||||
SvelteDialogMixIn,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { SvelteDialogManagerDependencies } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog";
|
||||
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte";
|
||||
import { SvelteDialogSession } from "@/modules/services/SvelteDialogSession";
|
||||
|
||||
export class ShimModal {
|
||||
export class BrowserModal {
|
||||
contentEl: HTMLElement;
|
||||
titleEl: HTMLElement;
|
||||
modalEl: HTMLElement;
|
||||
@@ -45,19 +45,14 @@ export class ShimModal {
|
||||
}
|
||||
onOpen() {}
|
||||
onClose() {}
|
||||
setPlaceholder(p: string) {}
|
||||
setTitle(t: string) {
|
||||
this.titleEl.textContent = t;
|
||||
}
|
||||
}
|
||||
|
||||
const BrowserSvelteDialogBase = SvelteDialogMixIn(ShimModal, DialogHost);
|
||||
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserModal {
|
||||
private readonly session: SvelteDialogSession<T, U, C>;
|
||||
|
||||
export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceContext> extends BrowserSvelteDialogBase<
|
||||
T,
|
||||
U,
|
||||
C
|
||||
> {
|
||||
constructor(
|
||||
context: C,
|
||||
dependents: SvelteDialogManagerDependencies<C>,
|
||||
@@ -65,7 +60,26 @@ export class LiveSyncBrowserDialog<T, U, C extends ServiceContext = ServiceConte
|
||||
initialData?: U
|
||||
) {
|
||||
super();
|
||||
this.initDialog(context, dependents, component, initialData);
|
||||
this.session = new SvelteDialogSession({
|
||||
surface: this,
|
||||
context,
|
||||
dependencies: dependents,
|
||||
dialogHost: DialogHost,
|
||||
component,
|
||||
initialData,
|
||||
});
|
||||
}
|
||||
|
||||
override onOpen(): void {
|
||||
this.session.onOpen();
|
||||
}
|
||||
|
||||
override onClose(): void {
|
||||
this.session.onClose();
|
||||
}
|
||||
|
||||
waitForClose(): Promise<T | undefined> {
|
||||
return this.session.waitForClose();
|
||||
}
|
||||
}
|
||||
export class BrowserSvelteDialogManager<T extends ServiceContext> extends SvelteDialogManagerBase<T> {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { ICommandCompat } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
||||
import { FetchHttpHandler } from "@smithy/fetch-http-handler";
|
||||
import { _fetch } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
declare const MANIFEST_VERSION: string | undefined;
|
||||
declare const PACKAGE_VERSION: string | undefined;
|
||||
|
||||
export interface LiveSyncBrowserAPIServiceOptions {
|
||||
confirm: Confirm;
|
||||
getSystemVaultName(): string;
|
||||
appId?: string;
|
||||
isMobile?: () => boolean;
|
||||
fetch?: typeof _fetch;
|
||||
addLog?: (message: unknown, level: LOG_LEVEL, key?: string) => void;
|
||||
addCommand?: <TCommand extends ICommandCompat>(command: TCommand) => TCommand;
|
||||
showWindow?: (type: string) => Promise<void>;
|
||||
registerWindow?: <T>(type: string, factory: (leaf: T) => unknown) => void;
|
||||
addRibbonIcon?: (icon: string, title: string, callback: (event: MouseEvent) => unknown) => HTMLElement;
|
||||
registerProtocolHandler?: (action: string, handler: (params: Record<string, string>) => unknown) => void;
|
||||
addStatusBarItem?: () => HTMLElement | undefined;
|
||||
}
|
||||
|
||||
/** Browser application implementation of Commonlib's injected host API contract. */
|
||||
export class LiveSyncBrowserAPIService<T extends ServiceContext> extends InjectableAPIService<T> {
|
||||
private readonly options: LiveSyncBrowserAPIServiceOptions;
|
||||
|
||||
constructor(context: T, options: LiveSyncBrowserAPIServiceOptions) {
|
||||
super(context);
|
||||
this.options = options;
|
||||
this.addLog.setHandler((message, level, key) => {
|
||||
options.addLog?.(message, level, key);
|
||||
});
|
||||
}
|
||||
|
||||
get confirm(): Confirm {
|
||||
return this.options.confirm;
|
||||
}
|
||||
|
||||
getCustomFetchHandler(): FetchHttpHandler {
|
||||
return new FetchHttpHandler();
|
||||
}
|
||||
|
||||
isMobile(): boolean {
|
||||
return this.options.isMobile?.() ?? false;
|
||||
}
|
||||
|
||||
showWindow(type: string): Promise<void> {
|
||||
return this.options.showWindow?.(type) ?? Promise.resolve();
|
||||
}
|
||||
|
||||
getAppID(): string {
|
||||
return this.options.appId ?? this.options.getSystemVaultName();
|
||||
}
|
||||
|
||||
getSystemVaultName(): string {
|
||||
return this.options.getSystemVaultName();
|
||||
}
|
||||
|
||||
override getPlatform(): string {
|
||||
return "browser";
|
||||
}
|
||||
|
||||
getAppVersion(): string {
|
||||
return MANIFEST_VERSION ?? "0.0.0";
|
||||
}
|
||||
|
||||
getPluginVersion(): string {
|
||||
return PACKAGE_VERSION ?? "0.0.0";
|
||||
}
|
||||
|
||||
addCommand<TCommand extends ICommandCompat>(command: TCommand): TCommand {
|
||||
return this.options.addCommand?.(command) ?? command;
|
||||
}
|
||||
|
||||
registerWindow<T>(type: string, factory: (leaf: T) => unknown): void {
|
||||
this.options.registerWindow?.(type, factory);
|
||||
}
|
||||
|
||||
addRibbonIcon(
|
||||
icon: string,
|
||||
title: string,
|
||||
callback: (event: MouseEvent) => unknown
|
||||
): HTMLElement {
|
||||
const element = this.options.addRibbonIcon?.(icon, title, callback);
|
||||
if (!element) {
|
||||
throw new Error("Ribbon icons are not supported by this browser application");
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
registerProtocolHandler(
|
||||
action: string,
|
||||
handler: (params: Record<string, string>) => unknown
|
||||
): void {
|
||||
this.options.registerProtocolHandler?.(action, handler);
|
||||
}
|
||||
|
||||
override nativeFetch(request: string | Request, options?: RequestInit): Promise<Response> {
|
||||
const fetchImplementation = this.options.fetch ?? _fetch;
|
||||
return fetchImplementation(request, options);
|
||||
}
|
||||
|
||||
addStatusBarItem(): HTMLElement | undefined {
|
||||
return this.options.addStatusBarItem?.();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import type { BrowserServiceHostDependencies } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService";
|
||||
import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import type { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import type { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService";
|
||||
import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService";
|
||||
import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte";
|
||||
import { BrowserSvelteDialogManager } from "./BrowserSvelteDialogManager";
|
||||
|
||||
export interface LiveSyncBrowserUIServiceDependencies<T extends ServiceContext> {
|
||||
API: InjectableAPIService<T>;
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
control: ControlService<T>;
|
||||
replicator: ReplicatorService<T>;
|
||||
}
|
||||
|
||||
export class LiveSyncBrowserUIService<T extends ServiceContext> extends UIService<T> {
|
||||
override get dialogToCopy() {
|
||||
return DialogToCopy;
|
||||
}
|
||||
constructor(context: T, dependents: BrowserServiceHostDependencies<T>) {
|
||||
constructor(context: T, dependents: LiveSyncBrowserUIServiceDependencies<T>) {
|
||||
const browserConfirm = dependents.API.confirm;
|
||||
const obsidianSvelteDialogManager = new BrowserSvelteDialogManager<T>(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
Confirm,
|
||||
ConfirmActionLayout,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { UiInteractions, UiNotifications } from "@vrtmrz/ui-interactions";
|
||||
|
||||
const DEFAULT_LABELS = {
|
||||
confirmationTitle: "Confirmation",
|
||||
selectionTitle: "Select",
|
||||
yes: "Yes",
|
||||
no: "No",
|
||||
} as const;
|
||||
|
||||
export interface UiInteractionsConfirmOptions {
|
||||
ui: UiInteractions;
|
||||
notifications: UiNotifications;
|
||||
createActionAnchor: () => HTMLAnchorElement;
|
||||
}
|
||||
|
||||
function timeoutOption(timeoutSeconds?: number): { timeoutMs?: number } {
|
||||
return timeoutSeconds !== undefined && timeoutSeconds > 0
|
||||
? { timeoutMs: timeoutSeconds * 1_000 }
|
||||
: {};
|
||||
}
|
||||
|
||||
/** Adapts Commonlib's legacy confirmation contract to Fancy Kit capabilities. */
|
||||
export class UiInteractionsConfirm implements Confirm {
|
||||
readonly notifications: UiNotifications;
|
||||
|
||||
constructor(private readonly options: UiInteractionsConfirmOptions) {
|
||||
this.notifications = options.notifications;
|
||||
}
|
||||
|
||||
async askYesNo(message: string): Promise<"yes" | "no"> {
|
||||
const result = await this.options.ui.confirmAction(
|
||||
{
|
||||
title: DEFAULT_LABELS.confirmationTitle,
|
||||
message,
|
||||
actions: ["yes", "no"],
|
||||
labels: { yes: DEFAULT_LABELS.yes, no: DEFAULT_LABELS.no },
|
||||
defaultAction: "no",
|
||||
actionLayout: "vertical",
|
||||
},
|
||||
"legacy-confirm.ask-yes-no"
|
||||
);
|
||||
return result === "yes" ? "yes" : "no";
|
||||
}
|
||||
|
||||
async askString(
|
||||
title: string,
|
||||
key: string,
|
||||
placeholder: string,
|
||||
isPassword = false
|
||||
): Promise<string | false> {
|
||||
const prompt = isPassword
|
||||
? this.options.ui.promptPassword.bind(this.options.ui)
|
||||
: this.options.ui.promptText.bind(this.options.ui);
|
||||
const result = await prompt(
|
||||
{
|
||||
title,
|
||||
label: key,
|
||||
placeholder,
|
||||
},
|
||||
"legacy-confirm.ask-string"
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
async askYesNoDialog(
|
||||
message: string,
|
||||
opt: { title?: string; defaultOption?: "Yes" | "No"; timeout?: number } = {}
|
||||
): Promise<"yes" | "no"> {
|
||||
const result = await this.options.ui.confirmAction(
|
||||
{
|
||||
title: opt.title ?? DEFAULT_LABELS.confirmationTitle,
|
||||
message,
|
||||
actions: ["Yes", "No"],
|
||||
labels: { Yes: DEFAULT_LABELS.yes, No: DEFAULT_LABELS.no },
|
||||
defaultAction: opt.defaultOption ?? "No",
|
||||
actionLayout: "vertical",
|
||||
...timeoutOption(opt.timeout),
|
||||
},
|
||||
"legacy-confirm.ask-yes-no-dialog"
|
||||
);
|
||||
return result === "Yes" ? "yes" : "no";
|
||||
}
|
||||
|
||||
async askSelectString(message: string, items: string[]): Promise<string> {
|
||||
const result = await this.options.ui.pickOne(
|
||||
{
|
||||
items,
|
||||
getText: (item) => item,
|
||||
placeholder: message,
|
||||
},
|
||||
"legacy-confirm.ask-select-string"
|
||||
);
|
||||
return result ?? "";
|
||||
}
|
||||
|
||||
async askSelectStringDialogue<T extends readonly string[]>(
|
||||
message: string,
|
||||
buttons: T,
|
||||
opt: { title?: string; defaultAction: T[number]; timeout?: number }
|
||||
): Promise<T[number] | false> {
|
||||
const result = await this.options.ui.confirmAction(
|
||||
{
|
||||
title: opt.title ?? DEFAULT_LABELS.selectionTitle,
|
||||
message,
|
||||
actions: buttons,
|
||||
defaultAction: opt.defaultAction,
|
||||
actionLayout: "vertical",
|
||||
...timeoutOption(opt.timeout),
|
||||
},
|
||||
"legacy-confirm.ask-select-string-dialogue"
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
askInPopup(
|
||||
key: string,
|
||||
dialogText: string,
|
||||
anchorCallback: (anchor: HTMLAnchorElement) => void,
|
||||
durationMs?: number
|
||||
): void {
|
||||
const anchor = this.options.createActionAnchor();
|
||||
anchorCallback(anchor);
|
||||
this.options.notifications.show(key, {
|
||||
message: dialogText.replace("{HERE}", "").trim(),
|
||||
action: {
|
||||
label: anchor.textContent?.trim() || "Open",
|
||||
onSelect: () => anchor.click(),
|
||||
},
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
async confirmWithMessage(
|
||||
title: string,
|
||||
contentMd: string,
|
||||
buttons: string[],
|
||||
defaultAction: (typeof buttons)[number],
|
||||
timeout?: number,
|
||||
actionLayout?: ConfirmActionLayout
|
||||
): Promise<(typeof buttons)[number] | false> {
|
||||
const result = await this.options.ui.confirmAction(
|
||||
{
|
||||
title,
|
||||
message: contentMd,
|
||||
actions: buttons,
|
||||
defaultAction,
|
||||
actionLayout: actionLayout ?? "vertical",
|
||||
...timeoutOption(timeout),
|
||||
},
|
||||
"legacy-confirm.confirm-with-message"
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,215 @@
|
||||
import { BrowserServiceHub, type BrowserServiceHost } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { KeyValueDatabaseFactory } from "@vrtmrz/livesync-commonlib/compat/interfaces/KeyValueDatabase";
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import { DatabaseService } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService";
|
||||
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
|
||||
import type { ISettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
import { InjectableAppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService";
|
||||
import { InjectableConflictService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableConflictService";
|
||||
import { InjectableDatabaseEventService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableDatabaseEventService";
|
||||
import { InjectableFileProcessingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableFileProcessingService";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { InjectableRemoteService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableRemoteService";
|
||||
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
||||
import { InjectableReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicatorService";
|
||||
import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { InjectableTestService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTestService";
|
||||
import { InjectableTweakValueService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableTweakValueService";
|
||||
import { InjectableVaultServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableVaultService";
|
||||
|
||||
import { setLang, translateLiveSyncMessage } from "@/common/translation";
|
||||
import { BrowserConfirm } from "./BrowserConfirm";
|
||||
import { createBrowserKeyValueDatabaseFactory } from "./BrowserKeyValueDatabase";
|
||||
import {
|
||||
LiveSyncBrowserAPIService,
|
||||
type LiveSyncBrowserAPIServiceOptions,
|
||||
} from "./LiveSyncBrowserAPIService";
|
||||
import { LiveSyncBrowserUIService } from "./LiveSyncBrowserUIService";
|
||||
|
||||
export type LiveSyncBrowserServiceHubOptions<T extends ServiceContext> = {
|
||||
export interface LiveSyncBrowserSettingsPersistence {
|
||||
load(): Promise<ObsidianLiveSyncSettings | undefined>;
|
||||
save(settings: ObsidianLiveSyncSettings): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveSyncBrowserRestartPolicy {
|
||||
schedule(): void;
|
||||
perform?: () => void;
|
||||
ask?: (message?: string) => void;
|
||||
isScheduled?: () => boolean;
|
||||
}
|
||||
|
||||
export interface LiveSyncBrowserServiceHubOptions<T extends ServiceContext> {
|
||||
context?: T;
|
||||
getSystemVaultName?: () => string;
|
||||
settings?: LiveSyncBrowserSettingsPersistence;
|
||||
restart?: LiveSyncBrowserRestartPolicy;
|
||||
openKeyValueDatabase?: KeyValueDatabaseFactory;
|
||||
};
|
||||
API?: Omit<LiveSyncBrowserAPIServiceOptions, "confirm" | "getSystemVaultName">;
|
||||
}
|
||||
|
||||
function createLiveSyncBrowserHost<T extends ServiceContext>(): BrowserServiceHost<T> {
|
||||
return {
|
||||
createAPI(context) {
|
||||
return new BrowserAPIService(context, {
|
||||
confirm: new BrowserConfirm(context),
|
||||
});
|
||||
},
|
||||
createUI(context, dependencies) {
|
||||
return new LiveSyncBrowserUIService(context, dependencies);
|
||||
},
|
||||
};
|
||||
class LiveSyncBrowserAppLifecycleService<
|
||||
T extends ServiceContext,
|
||||
> extends InjectableAppLifecycleService<T> {}
|
||||
|
||||
class LiveSyncBrowserDatabaseService<T extends ServiceContext> extends DatabaseService<T> {}
|
||||
|
||||
class LiveSyncBrowserKeyValueDBService<T extends ServiceContext> extends KeyValueDBService<T> {}
|
||||
|
||||
class LiveSyncBrowserConfigService<T extends ServiceContext> extends ConfigService<T> {
|
||||
constructor(
|
||||
context: T,
|
||||
private readonly setting: ISettingService
|
||||
) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
getSmallConfig(key: string): string | null {
|
||||
return this.setting.getSmallConfig(key);
|
||||
}
|
||||
|
||||
setSmallConfig(key: string, value: string): void {
|
||||
this.setting.setSmallConfig(key, value);
|
||||
}
|
||||
|
||||
deleteSmallConfig(key: string): void {
|
||||
this.setting.deleteSmallConfig(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** LiveSync-owned service composition shared by WebApp and WebPeer. */
|
||||
export class LiveSyncBrowserServiceHub<T extends ServiceContext> extends InjectableServiceHub<T> {
|
||||
constructor(options: LiveSyncBrowserServiceHubOptions<T>) {
|
||||
const context =
|
||||
options.context ??
|
||||
(new ServiceContext({
|
||||
translate: translateLiveSyncMessage,
|
||||
}) as T);
|
||||
const API = new LiveSyncBrowserAPIService(context, {
|
||||
...options.API,
|
||||
confirm: new BrowserConfirm(context),
|
||||
getSystemVaultName: options.getSystemVaultName ?? (() => "livesync-browser"),
|
||||
});
|
||||
const conflict = new InjectableConflictService(context);
|
||||
const fileProcessing = new InjectableFileProcessingService(context);
|
||||
const setting = new InjectableSettingService(context, {
|
||||
APIService: API,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
});
|
||||
const settingsPersistence = options.settings;
|
||||
setting.loadData.setHandler(
|
||||
settingsPersistence
|
||||
? () => settingsPersistence.load()
|
||||
: () => Promise.resolve(undefined)
|
||||
);
|
||||
setting.saveData.setHandler(
|
||||
settingsPersistence
|
||||
? (settings) => settingsPersistence.save(settings)
|
||||
: () => Promise.resolve()
|
||||
);
|
||||
|
||||
const appLifecycle = new LiveSyncBrowserAppLifecycleService(context, {
|
||||
settingService: setting,
|
||||
});
|
||||
const restartPolicy = options.restart;
|
||||
const scheduleRestart = restartPolicy ? () => restartPolicy.schedule() : () => {};
|
||||
appLifecycle.scheduleRestart.setHandler(scheduleRestart);
|
||||
appLifecycle.performRestart.setHandler(
|
||||
restartPolicy?.perform ? () => restartPolicy.perform?.() : scheduleRestart
|
||||
);
|
||||
appLifecycle.askRestart.setHandler(
|
||||
restartPolicy?.ask ? (message) => restartPolicy.ask?.(message) : scheduleRestart
|
||||
);
|
||||
appLifecycle.isReloadingScheduled.setHandler(
|
||||
restartPolicy?.isScheduled ? () => restartPolicy.isScheduled?.() ?? false : () => false
|
||||
);
|
||||
|
||||
const databaseEvents = new InjectableDatabaseEventService(context);
|
||||
const path = new PathServiceCompat(context, {
|
||||
settingService: setting,
|
||||
});
|
||||
const vault = new InjectableVaultServiceCompat(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const database = new LiveSyncBrowserDatabaseService(context, {
|
||||
pouchDB: PouchDB,
|
||||
path,
|
||||
vault,
|
||||
setting,
|
||||
API,
|
||||
});
|
||||
const config = new LiveSyncBrowserConfigService(context, setting);
|
||||
const replicator = new InjectableReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
databaseEventService: databaseEvents,
|
||||
});
|
||||
const remote = new InjectableRemoteService(context, {
|
||||
pouchDB: PouchDB,
|
||||
APIService: API,
|
||||
appLifecycle,
|
||||
setting,
|
||||
});
|
||||
const replication = new InjectableReplicationService(context, {
|
||||
APIService: API,
|
||||
appLifecycleService: appLifecycle,
|
||||
replicatorService: replicator,
|
||||
settingService: setting,
|
||||
fileProcessingService: fileProcessing,
|
||||
databaseService: database,
|
||||
});
|
||||
const keyValueDB = new LiveSyncBrowserKeyValueDBService(context, {
|
||||
openKeyValueDatabase:
|
||||
options.openKeyValueDatabase ?? createBrowserKeyValueDatabaseFactory(),
|
||||
appLifecycle,
|
||||
databaseEvents,
|
||||
vault,
|
||||
});
|
||||
const control = new ControlService(context, {
|
||||
appLifecycleService: appLifecycle,
|
||||
databaseService: database,
|
||||
fileProcessingService: fileProcessing,
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
replicatorService: replicator,
|
||||
});
|
||||
const ui = new LiveSyncBrowserUIService(context, {
|
||||
API,
|
||||
appLifecycle,
|
||||
config,
|
||||
control,
|
||||
replicator,
|
||||
});
|
||||
|
||||
super(context, {
|
||||
API,
|
||||
appLifecycle,
|
||||
conflict,
|
||||
config,
|
||||
control,
|
||||
database,
|
||||
databaseEvents,
|
||||
fileProcessing,
|
||||
keyValueDB,
|
||||
path,
|
||||
remote,
|
||||
replication,
|
||||
replicator,
|
||||
setting,
|
||||
test: new InjectableTestService(context),
|
||||
tweakValue: new InjectableTweakValueService(context),
|
||||
ui,
|
||||
vault,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createLiveSyncBrowserServiceHub<T extends ServiceContext>(
|
||||
options: LiveSyncBrowserServiceHubOptions<T> = {}
|
||||
): BrowserServiceHub<T> {
|
||||
const context = options.context ?? (new ServiceContext({ translate: translateLiveSyncMessage }) as T);
|
||||
return new BrowserServiceHub<T>({
|
||||
...options,
|
||||
context,
|
||||
onDisplayLanguageChanged: setLang,
|
||||
host: createLiveSyncBrowserHost<T>(),
|
||||
});
|
||||
): LiveSyncBrowserServiceHub<T> {
|
||||
return new LiveSyncBrowserServiceHub(options);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
observeServiceComposition,
|
||||
observeServiceContext,
|
||||
SERVICE_CONTEXT_MEMBERS,
|
||||
} from "../../../test/contracts/serviceContext";
|
||||
import { createLiveSyncBrowserServiceHub } from "./createLiveSyncBrowserServiceHub";
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
type LiveSyncBrowserServiceHubOptions,
|
||||
} from "./createLiveSyncBrowserServiceHub";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("LiveSync browser service context contract", () => {
|
||||
it("preserves one injected context and its API results throughout the Webapp composition", () => {
|
||||
@@ -25,4 +33,57 @@ describe("LiveSync browser service context contract", () => {
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it("binds browser identity, persistence, restart policy, and native Fetch while composing the hub", async () => {
|
||||
const deviceLocalSettings = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => deviceLocalSettings.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => deviceLocalSettings.set(key, value),
|
||||
removeItem: (key: string) => deviceLocalSettings.delete(key),
|
||||
clear: () => deviceLocalSettings.clear(),
|
||||
});
|
||||
const savedSettings: (typeof DEFAULT_SETTINGS)[] = [];
|
||||
const restartCalls: string[] = [];
|
||||
const nativeFetch = vi.fn(async () => new Response("ok"));
|
||||
const options: LiveSyncBrowserServiceHubOptions<ReturnType<typeof createServiceContext>> = {
|
||||
getSystemVaultName: () => "browser-vault",
|
||||
settings: {
|
||||
load: async () => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
couchDB_DBNAME: "browser-database",
|
||||
}),
|
||||
save: async (settings) => {
|
||||
savedSettings.push(settings);
|
||||
},
|
||||
},
|
||||
restart: {
|
||||
schedule: () => restartCalls.push("schedule"),
|
||||
perform: () => restartCalls.push("perform"),
|
||||
ask: (message) => restartCalls.push(`ask:${message ?? ""}`),
|
||||
isScheduled: () => true,
|
||||
},
|
||||
API: {
|
||||
fetch: nativeFetch as typeof fetch,
|
||||
},
|
||||
};
|
||||
|
||||
const hub = createLiveSyncBrowserServiceHub(options);
|
||||
|
||||
expect(hub.API.getSystemVaultName()).toBe("browser-vault");
|
||||
expect(hub.API.getPlatform()).toBe("browser");
|
||||
const response = await hub.API.nativeFetch("https://example.invalid/");
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(nativeFetch).toHaveBeenCalledWith("https://example.invalid/", undefined);
|
||||
await hub.setting.loadSettings();
|
||||
expect(hub.setting.currentSettings().couchDB_DBNAME).toBe("browser-database");
|
||||
savedSettings.length = 0;
|
||||
await hub.setting.saveSettingData();
|
||||
expect(savedSettings).toHaveLength(1);
|
||||
|
||||
hub.appLifecycle.scheduleRestart();
|
||||
hub.appLifecycle.performRestart();
|
||||
hub.appLifecycle.askRestart("restart now");
|
||||
expect(hub.appLifecycle.isReloadingScheduled()).toBe(true);
|
||||
expect(restartCalls).toEqual(["schedule", "perform", "ask:restart now"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { renderMessageMarkdown } from "./renderMessageMarkdown";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
message: string;
|
||||
buttons: string[];
|
||||
actionLayout?: ConfirmActionLayout;
|
||||
commit: (button: string) => void;
|
||||
};
|
||||
type ConfirmActionLayout = "auto" | "vertical";
|
||||
let { title, message, buttons, actionLayout, commit }: Props = $props();
|
||||
const renderedMessage = $derived(renderMessageMarkdown(message));
|
||||
|
||||
function handleEsc(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
commit("");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<popup>
|
||||
<header>{title}</header>
|
||||
<article><div class="msg">{@html renderedMessage}</div></article>
|
||||
<div class:vertical={actionLayout === "vertical"} class="buttons">
|
||||
{#each buttons as button}
|
||||
<button onclick={() => commit(button)}>{button}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</popup>
|
||||
<div class="background" onclick={() => commit("")} onkeydown={handleEsc} role="none"></div>
|
||||
|
||||
<style>
|
||||
popup {
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
max-width: 70vw;
|
||||
max-height: 80vh;
|
||||
margin: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 50vw;
|
||||
min-height: 50vh;
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--background-primary-alt);
|
||||
justify-content: space-between;
|
||||
}
|
||||
popup header {
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
padding: 1em;
|
||||
border-bottom: 1px solid var(--background-primary-alt);
|
||||
font: size 1.4em;
|
||||
}
|
||||
popup article {
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
popup article .msg {
|
||||
width: 100%;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
popup article .msg :global(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
popup article .msg :global(:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
popup article .msg :global(pre) {
|
||||
overflow-x: auto;
|
||||
padding: 0.75em;
|
||||
border-radius: 4px;
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
popup article .msg :global(code) {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
popup article .msg :global(blockquote) {
|
||||
margin: 0;
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
popup article .msg :global(ul),
|
||||
popup article .msg :global(ol) {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
popup article .msg :global(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
popup article .msg :global(th),
|
||||
popup article .msg :global(td) {
|
||||
padding: 0.4em 0.6em;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
popup article .msg :global(a) {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
popup .buttons {
|
||||
border-top: 1px solid var(--background-primary-alt);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
}
|
||||
popup .buttons button {
|
||||
margin: 0 0.5em;
|
||||
background-color: var(--background-primary-alt);
|
||||
}
|
||||
popup .buttons.vertical {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
popup .buttons.vertical button {
|
||||
margin: 0.25em 0;
|
||||
width: 100%;
|
||||
}
|
||||
popup ~ .background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.125);
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
title: string;
|
||||
message: string;
|
||||
initialText?: string;
|
||||
placeholder?: string;
|
||||
isPassword?: boolean;
|
||||
commit: (text: string | false) => void;
|
||||
};
|
||||
const { title, message, commit, initialText, placeholder, isPassword }: Props = $props();
|
||||
|
||||
function initialTextSeed(): string {
|
||||
return initialText ?? "";
|
||||
}
|
||||
|
||||
let text = $state(initialTextSeed());
|
||||
const type = $derived(isPassword ? "password" : "text");
|
||||
function cancel() {
|
||||
commit(false);
|
||||
}
|
||||
|
||||
function handleKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
handleCancel(event);
|
||||
} else if (event.key === "Enter") {
|
||||
handleCommit(event);
|
||||
}
|
||||
}
|
||||
function handleCancel(event: KeyboardEvent | MouseEvent) {
|
||||
cancel();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleCommit(event: KeyboardEvent | MouseEvent) {
|
||||
commit(text);
|
||||
event.preventDefault();
|
||||
}
|
||||
let textEl: HTMLInputElement;
|
||||
$effect(() => {
|
||||
textEl.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<popup>
|
||||
<header>{title}</header>
|
||||
<article>
|
||||
<div class="msg">{message}</div>
|
||||
<div class="input">
|
||||
<input
|
||||
bind:this={textEl}
|
||||
{type}
|
||||
bind:value={text}
|
||||
{placeholder}
|
||||
onkeydown={handleKey}
|
||||
onkeyup={handleKey}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="buttons">
|
||||
<button onclick={handleCommit}>OK</button>
|
||||
<button onclick={handleCancel}>Cancel</button>
|
||||
</div>
|
||||
</popup>
|
||||
<div class="background" onclick={handleCancel} onkeydown={handleKey} role="none"></div>
|
||||
|
||||
<style>
|
||||
popup {
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
max-width: 70vw;
|
||||
max-height: 80vh;
|
||||
margin: auto;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: 50vw;
|
||||
min-height: 50vh;
|
||||
backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--background-primary-alt);
|
||||
justify-content: space-between;
|
||||
}
|
||||
popup header {
|
||||
background: var(--background-primary);
|
||||
color: var(--text-normal);
|
||||
padding: 1em;
|
||||
border-bottom: 1px solid var(--background-primary-alt);
|
||||
font: size 1.4em;
|
||||
}
|
||||
popup article {
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
popup article .msg {
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
popup .buttons {
|
||||
border-top: 1px solid var(--background-primary-alt);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
}
|
||||
popup .buttons button {
|
||||
margin: 0 0.5em;
|
||||
background-color: var(--background-primary-alt);
|
||||
}
|
||||
popup ~ .background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.125);
|
||||
}
|
||||
</style>
|
||||
@@ -19,3 +19,13 @@ markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) =>
|
||||
export function renderMessageMarkdown(message: string): string {
|
||||
return markdownRenderer.render(message);
|
||||
}
|
||||
|
||||
export function renderMessageMarkdownInto(container: HTMLElement, message: string): void {
|
||||
const DOMParserConstructor = container.ownerDocument.defaultView?.DOMParser;
|
||||
if (!DOMParserConstructor) {
|
||||
container.textContent = message;
|
||||
return;
|
||||
}
|
||||
const parsed = new DOMParserConstructor().parseFromString(renderMessageMarkdown(message), "text/html");
|
||||
container.replaceChildren(...Array.from(parsed.body.childNodes));
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Native DOM creation for browser applications which run outside Obsidian.
|
||||
*
|
||||
* Obsidian adds creation helpers to its own DOM environment. The standalone
|
||||
* Webapp and WebPeer hosts do not own those prototype extensions, and the
|
||||
* Webapp compatibility layer implements them on top of this native boundary.
|
||||
* WebApp and WebPeer hosts use this native boundary instead of installing
|
||||
* Obsidian-shaped prototype extensions.
|
||||
*/
|
||||
type NativeDocumentCreation = Pick<Document, "createElement" | "createDocumentFragment">;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.0-rc.1-cli",
|
||||
"version": "1.0.2-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"octagonal-wheels": "^0.1.51",
|
||||
"octagonal-wheels": "^0.1.52",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
|
||||
+75
-161
@@ -1,198 +1,112 @@
|
||||
# LiveSync WebApp
|
||||
Browser-based implementation of Self-hosted LiveSync using the FileSystem API.
|
||||
Note: (I vrtmrz have not tested this so much yet).
|
||||
# Self-hosted LiveSync WebApp
|
||||
|
||||
## Features
|
||||
WebApp is an experimental proof of concept for running Self-hosted LiveSync against a browser-authorised local Vault. It is not a replacement for the Obsidian plug-in, and it does not provide the plug-in's settings screen, command palette, or setup wizard.
|
||||
|
||||
- 🌐 Runs entirely in the browser
|
||||
- 📁 Uses FileSystem API to access your local vault
|
||||
- 🔄 Syncs with CouchDB, Object Storage server (compatible with Self-hosted LiveSync plug-in)
|
||||
- 🚫 No server-side code required!!
|
||||
- 💾 Settings stored in `.livesync/settings.json` within your vault
|
||||
- 👁️ Real-time file watching (Chrome 124+ with FileSystemObserver)
|
||||
## Capabilities
|
||||
|
||||
- Runs as a static application in the browser.
|
||||
- Reads and writes a user-selected Vault through the File System Access API.
|
||||
- Keeps previously selected directory handles in origin-scoped IndexedDB.
|
||||
- Loads and saves LiveSync settings in `.livesync/settings.json` inside the selected Vault.
|
||||
- Uses CouchDB as its primary continuous remote.
|
||||
- Provides optional P2P controls as a secondary connection.
|
||||
- Scans the Vault on start-up and when **Scan local files** is selected.
|
||||
- Watches external file changes automatically when `FileSystemObserver` is available.
|
||||
|
||||
The WebApp itself does not require an application server, but synchronisation still requires a compatible remote or P2P peer.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **FileSystem API support**:
|
||||
- Chrome/Edge 86+ (required)
|
||||
- Opera 72+ (required)
|
||||
- Safari 15.2+ (experimental, limited support)
|
||||
- Firefox: Not supported yet
|
||||
WebApp requires:
|
||||
|
||||
- **FileSystemObserver support** (optional, for real-time file watching):
|
||||
- Chrome 124+ (recommended)
|
||||
- Without this, files are only scanned on startup
|
||||
- a secure context, such as HTTPS or `localhost`;
|
||||
- `showDirectoryPicker()` and read-write access to the selected directory;
|
||||
- IndexedDB for saved directory handles and the local database; and
|
||||
- a CouchDB server which permits requests from the WebApp origin when CouchDB is used.
|
||||
|
||||
## Getting Started
|
||||
Automated browser coverage uses Chromium. Other browsers, background execution, and remote types other than CouchDB remain experimental. Use feature detection rather than relying on a fixed browser-version list.
|
||||
|
||||
### Installation
|
||||
## Use
|
||||
|
||||
```bash
|
||||
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
|
||||
# due to shared dependencies with webapp and main library
|
||||
npm install
|
||||
```
|
||||
Serve the built files over HTTPS, or from `localhost`, then open `webapp.html`.
|
||||
|
||||
### Development
|
||||
1. Select the local Vault root.
|
||||
2. Create or edit `.livesync/settings.json` in that Vault.
|
||||
3. Reload WebApp to apply the settings.
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm run dev -w livesync-webapp
|
||||
```
|
||||
|
||||
Or from the package directory:
|
||||
|
||||
```bash
|
||||
cd src/apps/webapp
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start a development server at `http://localhost:3000`.
|
||||
|
||||
### Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm run build -w livesync-webapp
|
||||
```
|
||||
|
||||
Or from the package directory:
|
||||
|
||||
```bash
|
||||
cd src/apps/webapp
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built files will be in the `dist` directory.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Open the webapp in your browser (`webapp.html`)
|
||||
2. Select a vault from history or grant access to a new directory
|
||||
3. Configure CouchDB connection by editing `.livesync/settings.json` in your vault
|
||||
- You can also copy data.json from Obsidian's plug-in folder.
|
||||
|
||||
Example `.livesync/settings.json`:
|
||||
For example, a minimal manually configured CouchDB connection includes:
|
||||
|
||||
```json
|
||||
{
|
||||
"couchDB_URI": "https://your-couchdb-server.com",
|
||||
"couchDB_USER": "your-username",
|
||||
"couchDB_PASSWORD": "your-password",
|
||||
"couchDB_DBNAME": "your-database",
|
||||
"couchDB_URI": "https://couchdb.example.com",
|
||||
"couchDB_USER": "username",
|
||||
"couchDB_PASSWORD": "password",
|
||||
"couchDB_DBNAME": "vault",
|
||||
"isConfigured": true,
|
||||
"liveSync": true,
|
||||
"syncOnSave": true
|
||||
}
|
||||
```
|
||||
|
||||
After editing, reload the page.
|
||||
The settings must match the remote database, including any encryption and compatibility settings which are already in use. The file may contain credentials and passphrases, so protect it accordingly. Review individual values instead of copying an Obsidian `data.json` file wholesale.
|
||||
|
||||
## Architecture
|
||||
### Optional P2P
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
webapp/
|
||||
├── adapters/ # FileSystem API adapters
|
||||
│ ├── FSAPITypes.ts
|
||||
│ ├── FSAPIPathAdapter.ts
|
||||
│ ├── FSAPITypeGuardAdapter.ts
|
||||
│ ├── FSAPIConversionAdapter.ts
|
||||
│ ├── FSAPIStorageAdapter.ts
|
||||
│ ├── FSAPIVaultAdapter.ts
|
||||
│ └── FSAPIFileSystemAdapter.ts
|
||||
├── managers/ # Event managers
|
||||
│ ├── FSAPIStorageEventManagerAdapter.ts
|
||||
│ └── StorageEventManagerFSAPI.ts
|
||||
├── serviceModules/ # Service implementations
|
||||
│ ├── FileAccessFSAPI.ts
|
||||
│ ├── ServiceFileAccessImpl.ts
|
||||
│ ├── DatabaseFileAccess.ts
|
||||
│ └── FSAPIServiceModules.ts
|
||||
├── bootstrap.ts # Vault picker + startup orchestration
|
||||
├── main.ts # LiveSync core bootstrap (after vault selected)
|
||||
├── vaultSelector.ts # FileSystem handle history and permission flow
|
||||
├── webapp.html # Main HTML entry
|
||||
├── index.html # Redirect entry for compatibility
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Adapters**: Implement `IFileSystemAdapter` interface using FileSystem API
|
||||
2. **Managers**: Handle storage events and file watching
|
||||
3. **Service Modules**: Integrate with LiveSyncBaseCore
|
||||
4. **Main**: Application initialisation and lifecycle management
|
||||
|
||||
### Service Hub
|
||||
|
||||
Uses `BrowserServiceHub` which provides:
|
||||
|
||||
- Database service (IndexedDB via PouchDB)
|
||||
- Settings service (file-based in `.livesync/settings.json`)
|
||||
- Replication service
|
||||
- File processing service
|
||||
- And more...
|
||||
The visible P2P controls are optional. Saving them does not select P2P as the main remote or mark an otherwise unconfigured Vault as configured. Use **Scan local files** before offering existing Vault content to a peer when automatic file observation is unavailable.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Real-time file watching**: Requires Chrome 124+ with FileSystemObserver
|
||||
- Without it, changes are only detected on manual refresh
|
||||
- **Performance**: Slower than native file system access
|
||||
- **Permissions**: Requires user to grant directory access (cached via IndexedDB)
|
||||
- **Browser support**: Limited to browsers with FileSystem API support
|
||||
|
||||
## Differences from CLI Version
|
||||
|
||||
- Uses `BrowserServiceHub` instead of `HeadlessServiceHub`
|
||||
- Uses FileSystem API instead of Node.js `fs`
|
||||
- Settings stored in `.livesync/settings.json` in vault
|
||||
- Real-time file watching only with FileSystemObserver (Chrome 124+)
|
||||
|
||||
## Differences from Obsidian Plug-in
|
||||
|
||||
- No Obsidian-specific modules (UI, settings dialogue, etc.)
|
||||
- Simplified configuration
|
||||
- No plug-in/theme sync features
|
||||
- No internal file handling (`.obsidian` folder)
|
||||
|
||||
## Development Notes
|
||||
|
||||
- TypeScript configuration: Uses project's tsconfig.json
|
||||
- Module resolution: Aliased paths via Vite config
|
||||
- External dependencies: Bundled by Vite
|
||||
- There is no general settings screen, setup wizard, or command palette.
|
||||
- Object Storage and other main-remote configurations remain experimental and are not covered by the current WebApp browser tests.
|
||||
- Without `FileSystemObserver`, external changes are detected only by a start-up or manual scan.
|
||||
- A browser may suspend the page, discard permissions, or evict origin-scoped storage.
|
||||
- The File System Access API is not available in every browser.
|
||||
- Customisation Sync and Obsidian-specific Vault features are unavailable.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to get directory access"
|
||||
### A Vault cannot be opened
|
||||
|
||||
- Make sure you're using a supported browser
|
||||
- Try refreshing the page
|
||||
- Clear browser cache and IndexedDB
|
||||
- Confirm that WebApp is served over HTTPS or from `localhost`.
|
||||
- Grant read-write access when the browser prompts.
|
||||
- If a saved handle no longer has permission, select **Choose new vault folder** and choose the same directory again.
|
||||
|
||||
### "Settings not found"
|
||||
### Settings are not loaded
|
||||
|
||||
- Check that `.livesync/settings.json` exists in your vault directory
|
||||
- Verify the JSON format is valid
|
||||
- Create the file manually if needed
|
||||
- Confirm that `.livesync/settings.json` exists under the selected Vault root.
|
||||
- Confirm that the file contains one valid JSON object.
|
||||
- Reload WebApp after editing the file.
|
||||
- Check the status line and browser console for the first reported error.
|
||||
|
||||
### "File watching not working"
|
||||
### External file changes are not detected
|
||||
|
||||
- Make sure you're using Chrome 124 or later
|
||||
- Check browser console for FileSystemObserver messages
|
||||
- Try manually triggering sync if automatic watching isn't available
|
||||
- Select **Scan local files** after making changes outside WebApp.
|
||||
- Check whether the browser provides `FileSystemObserver`.
|
||||
- Keep the page open while automatic watching or synchronisation is expected.
|
||||
|
||||
### "Sync not working"
|
||||
### CouchDB does not synchronise
|
||||
|
||||
- Verify CouchDB credentials
|
||||
- Check browser console for errors
|
||||
- Ensure CouchDB server is accessible (CORS enabled)
|
||||
- Confirm the URL, credentials, database name, encryption settings, and compatibility settings.
|
||||
- Confirm that CouchDB permits requests from the WebApp origin.
|
||||
- Check the browser network inspector and console for the rejected request.
|
||||
|
||||
## License
|
||||
## Development
|
||||
|
||||
Same as the main Self-hosted LiveSync project.
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm run dev --workspace livesync-webapp
|
||||
npm run build --workspace livesync-webapp
|
||||
npm run test:unit --workspace livesync-webapp
|
||||
npm run test:browser --workspace livesync-webapp
|
||||
```
|
||||
|
||||
The production files are written to `src/apps/webapp/dist/`. App-owned unit tests are stored in `test/apps/webapp/`, outside the Community Review source boundary. The browser test builds the production artefact, selects an OPFS-backed test Vault in Chromium, and verifies start-up and P2P setting isolation.
|
||||
|
||||
## Composition
|
||||
|
||||
`WebAppRuntime.ts` owns the LiveSync service composition and lifecycle. `main.ts` owns Vault selection and the small browser shell, while `adapters/`, `managers/`, and `serviceModules/` provide the File System Access API boundary.
|
||||
|
||||
## Licence
|
||||
|
||||
The same licence as the main Self-hosted LiveSync project applies.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte";
|
||||
import BrowserP2PTransportSettings from "@/apps/browser/BrowserP2PTransportSettings.svelte";
|
||||
import type { WebAppRuntime } from "./WebAppRuntime";
|
||||
|
||||
interface Props {
|
||||
runtime: WebAppRuntime;
|
||||
}
|
||||
|
||||
let { runtime }: Props = $props();
|
||||
let isScanning = $state(false);
|
||||
let scanStatus = $state("");
|
||||
|
||||
async function scanLocalFiles() {
|
||||
isScanning = true;
|
||||
scanStatus = "Scanning local files…";
|
||||
try {
|
||||
scanStatus = (await runtime.scanLocalFiles())
|
||||
? "Local files are ready for synchronisation."
|
||||
: "The local file scan could not be completed.";
|
||||
} catch (error) {
|
||||
scanStatus = `The local file scan failed: ${String(error)}`;
|
||||
} finally {
|
||||
isScanning = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="local-file-actions">
|
||||
<button type="button" disabled={isScanning} onclick={scanLocalFiles}>Scan local files</button>
|
||||
<span role="status" aria-live="polite">{scanStatus}</span>
|
||||
</div>
|
||||
|
||||
<BrowserP2PTransportSettings host={runtime.p2pPaneHost} />
|
||||
<P2PReplicatorPane
|
||||
host={runtime.p2pPaneHost}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.local-file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
/** Browser runtime for Self-hosted LiveSync over the File System Access API. */
|
||||
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type LOG_LEVEL,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
collectFilesOnStorage,
|
||||
updateToDatabase,
|
||||
useOfflineScanner,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
|
||||
import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize";
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
type LiveSyncBrowserServiceHub,
|
||||
type LiveSyncBrowserServiceHubOptions,
|
||||
} from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
|
||||
|
||||
const SETTINGS_DIR = ".livesync";
|
||||
const SETTINGS_FILE = "settings.json";
|
||||
|
||||
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
liveSync: false,
|
||||
syncOnSave: true,
|
||||
syncOnStart: false,
|
||||
savingDelay: 200,
|
||||
lessInformationInLog: false,
|
||||
gcDelay: 0,
|
||||
periodicReplication: false,
|
||||
periodicReplicationInterval: 60,
|
||||
isConfigured: false,
|
||||
// CouchDB settings - user needs to configure these
|
||||
couchDB_URI: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
couchDB_DBNAME: "",
|
||||
// Disable features which are not available in the WebApp.
|
||||
usePluginSync: false,
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
};
|
||||
|
||||
export type WebAppRuntimeStatusKind = "info" | "warning" | "error" | "success";
|
||||
|
||||
export interface WebAppRuntimeOptions {
|
||||
reportStatus?: (kind: WebAppRuntimeStatusKind, message: string) => void;
|
||||
scheduleReload?: (delayMilliseconds: number) => void;
|
||||
}
|
||||
|
||||
export class WebAppRuntime {
|
||||
private readonly rootHandle: FileSystemDirectoryHandle;
|
||||
private readonly reportStatus: NonNullable<WebAppRuntimeOptions["reportStatus"]>;
|
||||
private readonly scheduleReload: NonNullable<WebAppRuntimeOptions["scheduleReload"]>;
|
||||
private core: LiveSyncBaseCore<ServiceContext, never> | null = null;
|
||||
private serviceHub: LiveSyncBrowserServiceHub<ServiceContext> | null = null;
|
||||
private platformServiceModules: FSAPIServiceModules | null = null;
|
||||
private p2p: UseP2PReplicatorResult | null = null;
|
||||
private paneHost: P2PReplicatorPaneHost | null = null;
|
||||
private restartScheduled = false;
|
||||
|
||||
constructor(rootHandle: FileSystemDirectoryHandle, options: WebAppRuntimeOptions = {}) {
|
||||
this.rootHandle = rootHandle;
|
||||
this.reportStatus = options.reportStatus ?? (() => {});
|
||||
this.scheduleReload =
|
||||
options.scheduleReload ??
|
||||
((delayMilliseconds) => {
|
||||
compatGlobal.setTimeout(() => {
|
||||
compatGlobal.location.reload();
|
||||
}, delayMilliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void {
|
||||
this.serviceHub?.API.addLog(message, level, key);
|
||||
}
|
||||
|
||||
private createServiceOptions(): LiveSyncBrowserServiceHubOptions<ServiceContext> {
|
||||
return {
|
||||
getSystemVaultName: () => this.rootHandle.name || "livesync-webapp",
|
||||
settings: {
|
||||
save: async (data) => {
|
||||
try {
|
||||
await this.saveSettingsToFile(data);
|
||||
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
} catch (error) {
|
||||
this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings");
|
||||
}
|
||||
},
|
||||
load: async () => {
|
||||
try {
|
||||
const data = await this.loadSettingsFromFile();
|
||||
if (data) {
|
||||
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
|
||||
}
|
||||
} catch {
|
||||
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
|
||||
}
|
||||
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
|
||||
},
|
||||
},
|
||||
restart: {
|
||||
schedule: () => this.scheduleRestart(),
|
||||
perform: () => this.scheduleRestart(),
|
||||
ask: () => this.scheduleRestart(),
|
||||
isScheduled: () => this.restartScheduled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleRestart(): void {
|
||||
if (this.restartScheduled) {
|
||||
return;
|
||||
}
|
||||
this.restartScheduled = true;
|
||||
void (async () => {
|
||||
this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
|
||||
await this.shutdown();
|
||||
this.scheduleReload(1000);
|
||||
})();
|
||||
}
|
||||
|
||||
get events(): LiveSyncEventHub {
|
||||
if (!this.serviceHub) {
|
||||
throw new Error("The WebApp service hub is not initialised");
|
||||
}
|
||||
return this.serviceHub.context.events;
|
||||
}
|
||||
|
||||
get p2pPaneHost(): P2PReplicatorPaneHost {
|
||||
if (!this.paneHost) {
|
||||
throw new Error("The WebApp P2P pane host is not initialised");
|
||||
}
|
||||
return this.paneHost;
|
||||
}
|
||||
|
||||
async scanLocalFiles(): Promise<boolean> {
|
||||
const core = this.core;
|
||||
const fileAccess = this.platformServiceModules?.vaultAccess;
|
||||
if (!core || !fileAccess) {
|
||||
throw new Error("The WebApp core is not initialised");
|
||||
}
|
||||
|
||||
fileAccess.fsapiAdapter.clearCache();
|
||||
await fileAccess.fsapiAdapter.scanDirectory();
|
||||
|
||||
const log = (message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void => {
|
||||
this.addLog(message, level, key);
|
||||
};
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const { storageFileNameMap, storageFileNames } = await collectFilesOnStorage(core, settings, log);
|
||||
|
||||
let succeeded = true;
|
||||
for (const path of storageFileNames) {
|
||||
try {
|
||||
await updateToDatabase(core, log, LOG_LEVEL_INFO, storageFileNameMap[path]);
|
||||
} catch (error) {
|
||||
succeeded = false;
|
||||
this.addLog(`Failed to import ${path}: ${String(error)}`, LOG_LEVEL_NOTICE, "scan");
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.core) {
|
||||
throw new Error("The WebApp runtime has already been started");
|
||||
}
|
||||
|
||||
// Create service context and hub
|
||||
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>(this.createServiceOptions());
|
||||
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
|
||||
this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise");
|
||||
this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise");
|
||||
|
||||
// Create LiveSync core
|
||||
this.core = new LiveSyncBaseCore<ServiceContext, never>(
|
||||
this.serviceHub,
|
||||
(core, serviceHub) => {
|
||||
const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
|
||||
this.platformServiceModules = serviceModules;
|
||||
return serviceModules;
|
||||
},
|
||||
() => [],
|
||||
() => [] as never[], // No add-ons
|
||||
(core) => {
|
||||
useOfflineScanner(core);
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
this.p2p = useP2PReplicatorFeature(core);
|
||||
this.paneHost = {
|
||||
services: core.services,
|
||||
p2p: this.p2p,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await this.startCore();
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.shutdown();
|
||||
} catch (shutdownError) {
|
||||
this.addLog(`Failed to clean up after start failure: ${String(shutdownError)}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
// Create .livesync directory if it does not exist
|
||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
|
||||
|
||||
// Create/overwrite settings.json
|
||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
|
||||
try {
|
||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
|
||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("The WebApp settings file does not contain an object");
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// The file does not exist yet.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async startCore(): Promise<void> {
|
||||
if (!this.core) {
|
||||
throw new Error("Core not initialised");
|
||||
}
|
||||
|
||||
try {
|
||||
this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start");
|
||||
|
||||
const loadResult = await this.core.services.control.onLoad();
|
||||
if (!loadResult) {
|
||||
this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start");
|
||||
throw new Error("Failed to initialise LiveSync");
|
||||
}
|
||||
|
||||
await this.core.services.control.onReady();
|
||||
|
||||
this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready");
|
||||
|
||||
// Check if configured
|
||||
const settings = this.core.services.setting.currentSettings();
|
||||
if (!settings.isConfigured) {
|
||||
this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration");
|
||||
this.showWarning("Please configure CouchDB connection in settings");
|
||||
} else {
|
||||
this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration");
|
||||
this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration");
|
||||
this.showSuccess("LiveSync is ready!");
|
||||
}
|
||||
|
||||
// Scan the directory to populate file cache
|
||||
const fileAccess = this.platformServiceModules?.vaultAccess;
|
||||
if (fileAccess) {
|
||||
this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan");
|
||||
await fileAccess.fsapiAdapter.scanDirectory();
|
||||
const files = await fileAccess.fsapiAdapter.getFiles();
|
||||
this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan");
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start");
|
||||
this.showError(`Failed to start: ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
const core = this.core;
|
||||
if (!core) {
|
||||
return;
|
||||
}
|
||||
this.core = null;
|
||||
this.paneHost = null;
|
||||
this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown");
|
||||
|
||||
const storageEventManager = this.platformServiceModules?.storageEventManager;
|
||||
this.platformServiceModules = null;
|
||||
try {
|
||||
if (storageEventManager) {
|
||||
await storageEventManager.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await core.services.control.onUnload();
|
||||
this.p2p = null;
|
||||
this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown");
|
||||
this.serviceHub = null;
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.reportStatus("error", `Error: ${message}`);
|
||||
}
|
||||
|
||||
private showWarning(message: string): void {
|
||||
this.reportStatus("warning", `Warning: ${message}`);
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.reportStatus("success", message);
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { LiveSyncWebApp } from "./main";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
|
||||
import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
const historyStore = new VaultHistoryStore();
|
||||
let app: LiveSyncWebApp | null = null;
|
||||
|
||||
type LiveSyncWebAppDebugApi = {
|
||||
getApp: () => LiveSyncWebApp | null;
|
||||
historyStore: VaultHistoryStore;
|
||||
};
|
||||
|
||||
type LiveSyncWebAppGlobal = typeof compatGlobal & {
|
||||
livesyncApp?: LiveSyncWebAppDebugApi;
|
||||
};
|
||||
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = _activeDocument.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing element: #${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
function setStatus(kind: "info" | "warning" | "error" | "success", message: string): void {
|
||||
const statusEl = getRequiredElement<HTMLDivElement>("status");
|
||||
statusEl.className = kind;
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function setBusyState(isBusy: boolean): void {
|
||||
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
|
||||
pickNewBtn.disabled = isBusy;
|
||||
|
||||
const historyButtons = _activeDocument.querySelectorAll<HTMLButtonElement>(".vault-item button");
|
||||
historyButtons.forEach((button) => {
|
||||
button.disabled = isBusy;
|
||||
});
|
||||
}
|
||||
|
||||
function formatLastUsed(unixMillis: number): string {
|
||||
if (!unixMillis) {
|
||||
return "unknown";
|
||||
}
|
||||
return new Date(unixMillis).toLocaleString();
|
||||
}
|
||||
|
||||
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
||||
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
|
||||
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
|
||||
|
||||
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||
|
||||
listEl.replaceChildren();
|
||||
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
||||
|
||||
for (const item of items) {
|
||||
const row = createNativeElement(_activeDocument, "div");
|
||||
row.className = "vault-item";
|
||||
|
||||
const info = createNativeElement(_activeDocument, "div");
|
||||
info.className = "vault-item-info";
|
||||
|
||||
const name = createNativeElement(_activeDocument, "div");
|
||||
name.className = "vault-item-name";
|
||||
name.textContent = item.name;
|
||||
|
||||
const meta = createNativeElement(_activeDocument, "div");
|
||||
meta.className = "vault-item-meta";
|
||||
const label = item.id === lastUsedId ? "Last used" : "Used";
|
||||
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
|
||||
|
||||
info.append(name, meta);
|
||||
|
||||
const useButton = createNativeElement(_activeDocument, "button");
|
||||
useButton.type = "button";
|
||||
useButton.textContent = "Use this vault";
|
||||
useButton.addEventListener("click", () => {
|
||||
void startWithHistory(item);
|
||||
});
|
||||
|
||||
row.append(info, useButton);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
|
||||
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
|
||||
app = new LiveSyncWebApp(handle);
|
||||
await app.initialize();
|
||||
|
||||
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
|
||||
selectorEl.classList.add("is-hidden");
|
||||
}
|
||||
|
||||
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
|
||||
setBusyState(true);
|
||||
try {
|
||||
const handle = await historyStore.activateHistoryItem(item);
|
||||
await startWithHandle(handle);
|
||||
} catch (error) {
|
||||
setStatus("error", `Failed to open saved vault: ${String(error)}`);
|
||||
setBusyState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWithNewPicker(): Promise<void> {
|
||||
setBusyState(true);
|
||||
try {
|
||||
const handle = await historyStore.pickNewVault();
|
||||
await startWithHandle(handle);
|
||||
} catch (error) {
|
||||
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
|
||||
setBusyState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeVaultSelector(): Promise<void> {
|
||||
setStatus("info", "Select a vault folder to start LiveSync.");
|
||||
|
||||
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
|
||||
pickNewBtn.addEventListener("click", () => {
|
||||
void startWithNewPicker();
|
||||
});
|
||||
|
||||
await renderHistoryList();
|
||||
}
|
||||
|
||||
compatGlobal.addEventListener("load", () => {
|
||||
initializeVaultSelector().catch((error) => {
|
||||
setStatus("error", `Initialization failed: ${String(error)}`);
|
||||
});
|
||||
});
|
||||
|
||||
compatGlobal.addEventListener("beforeunload", () => {
|
||||
void app?.shutdown();
|
||||
});
|
||||
(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = {
|
||||
getApp: () => app,
|
||||
historyStore,
|
||||
};
|
||||
+150
-253
@@ -1,275 +1,172 @@
|
||||
/**
|
||||
* Self-hosted LiveSync WebApp
|
||||
* Browser-based version of Self-hosted LiveSync plugin using FileSystem API
|
||||
*/
|
||||
|
||||
import type { BrowserServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/BrowserServices";
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { initialiseServiceModulesFSAPI, type FSAPIServiceModules } from "./serviceModules/FSAPIServiceModules";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type LOG_LEVEL,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
|
||||
import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
|
||||
import { useCheckRemoteSize } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/checkRemoteSize";
|
||||
import { useSetupURIFeature } from "@/serviceFeatures/setupObsidian/setupUri";
|
||||
import { useRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import { useSetupManagerHandlersFeature } from "@/serviceFeatures/setupObsidian/setupManagerHandlers";
|
||||
import { useP2PReplicatorCommands } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorCommands";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { createNativeElement } from "@/apps/browserDom";
|
||||
import { compatGlobal, _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const SETTINGS_DIR = ".livesync";
|
||||
const SETTINGS_FILE = "settings.json";
|
||||
import { WebAppRuntime, type WebAppRuntimeStatusKind } from "./WebAppRuntime";
|
||||
import WebAppP2P from "./WebAppP2P.svelte";
|
||||
import { VaultHistoryStore, type VaultHistoryItem } from "./vaultSelector";
|
||||
|
||||
/**
|
||||
* Default settings for the webapp
|
||||
*/
|
||||
const DEFAULT_SETTINGS: Partial<ObsidianLiveSyncSettings> = {
|
||||
liveSync: false,
|
||||
syncOnSave: true,
|
||||
syncOnStart: false,
|
||||
savingDelay: 200,
|
||||
lessInformationInLog: false,
|
||||
gcDelay: 0,
|
||||
periodicReplication: false,
|
||||
periodicReplicationInterval: 60,
|
||||
isConfigured: false,
|
||||
// CouchDB settings - user needs to configure these
|
||||
couchDB_URI: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
couchDB_DBNAME: "",
|
||||
// Disable features not needed in webapp
|
||||
usePluginSync: false,
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
const historyStore = new VaultHistoryStore();
|
||||
let runtime: WebAppRuntime | null = null;
|
||||
|
||||
type LiveSyncWebAppDebugApi = {
|
||||
getRuntime: () => WebAppRuntime | null;
|
||||
historyStore: VaultHistoryStore;
|
||||
};
|
||||
|
||||
class LiveSyncWebApp {
|
||||
private rootHandle: FileSystemDirectoryHandle;
|
||||
private core: LiveSyncBaseCore<ServiceContext, never> | null = null;
|
||||
private serviceHub: BrowserServiceHub<ServiceContext> | null = null;
|
||||
private platformServiceModules: FSAPIServiceModules | null = null;
|
||||
type LiveSyncWebAppGlobal = typeof compatGlobal & {
|
||||
livesyncApp?: LiveSyncWebAppDebugApi;
|
||||
};
|
||||
|
||||
constructor(rootHandle: FileSystemDirectoryHandle) {
|
||||
this.rootHandle = rootHandle;
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = _activeDocument.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing element: #${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
private addLog(message: unknown, level: LOG_LEVEL = LOG_LEVEL_INFO, key?: string): void {
|
||||
this.serviceHub?.API.addLog(message, level, key);
|
||||
function setStatus(kind: WebAppRuntimeStatusKind, message: string): void {
|
||||
const statusEl = getRequiredElement<HTMLDivElement>("status");
|
||||
statusEl.className = kind;
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
|
||||
function setBusyState(isBusy: boolean): void {
|
||||
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
|
||||
pickNewBtn.disabled = isBusy;
|
||||
|
||||
const historyButtons = _activeDocument.querySelectorAll<HTMLButtonElement>(".vault-item button");
|
||||
historyButtons.forEach((button) => {
|
||||
button.disabled = isBusy;
|
||||
});
|
||||
}
|
||||
|
||||
function formatLastUsed(unixMillis: number): string {
|
||||
if (!unixMillis) {
|
||||
return "unknown";
|
||||
}
|
||||
return new Date(unixMillis).toLocaleString();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Create service context and hub
|
||||
this.serviceHub = createLiveSyncBrowserServiceHub<ServiceContext>();
|
||||
this.addLog("Self-hosted LiveSync WebApp", LOG_LEVEL_INFO, "initialise");
|
||||
this.addLog("Initialising...", LOG_LEVEL_VERBOSE, "initialise");
|
||||
this.addLog(`Vault directory: ${this.rootHandle.name}`, LOG_LEVEL_VERBOSE, "initialise");
|
||||
async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
||||
const listEl = getRequiredElement<HTMLDivElement>("vault-history-list");
|
||||
const emptyEl = getRequiredElement<HTMLParagraphElement>("vault-history-empty");
|
||||
|
||||
// Setup API service
|
||||
(this.serviceHub.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
|
||||
() => this.rootHandle?.name || "livesync-webapp"
|
||||
);
|
||||
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||
|
||||
// Setup settings handlers - save to .livesync folder
|
||||
const settingService = this.serviceHub.setting as InjectableSettingService<ServiceContext>;
|
||||
listEl.replaceChildren();
|
||||
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
||||
|
||||
settingService.saveData.setHandler(async (data: ObsidianLiveSyncSettings) => {
|
||||
try {
|
||||
await this.saveSettingsToFile(data);
|
||||
this.addLog("Saved to .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
} catch (error) {
|
||||
this.addLog(`Failed to save settings: ${String(error)}`, LOG_LEVEL_NOTICE, "settings");
|
||||
}
|
||||
for (const item of items) {
|
||||
const row = createNativeElement(_activeDocument, "div");
|
||||
row.className = "vault-item";
|
||||
|
||||
const info = createNativeElement(_activeDocument, "div");
|
||||
info.className = "vault-item-info";
|
||||
|
||||
const name = createNativeElement(_activeDocument, "div");
|
||||
name.className = "vault-item-name";
|
||||
name.textContent = item.name;
|
||||
|
||||
const meta = createNativeElement(_activeDocument, "div");
|
||||
meta.className = "vault-item-meta";
|
||||
const label = item.id === lastUsedId ? "Last used" : "Used";
|
||||
meta.textContent = `${label}: ${formatLastUsed(item.lastUsedAt)}`;
|
||||
|
||||
info.append(name, meta);
|
||||
|
||||
const useButton = createNativeElement(_activeDocument, "button");
|
||||
useButton.type = "button";
|
||||
useButton.textContent = "Use this vault";
|
||||
useButton.addEventListener("click", () => {
|
||||
void startWithHistory(item);
|
||||
});
|
||||
|
||||
settingService.loadData.setHandler(async (): Promise<ObsidianLiveSyncSettings | undefined> => {
|
||||
try {
|
||||
const data = await this.loadSettingsFromFile();
|
||||
if (data) {
|
||||
this.addLog("Loaded from .livesync/settings.json", LOG_LEVEL_VERBOSE, "settings");
|
||||
return { ...DEFAULT_SETTINGS, ...data } as ObsidianLiveSyncSettings;
|
||||
}
|
||||
} catch {
|
||||
this.addLog("Failed to load settings; using defaults", LOG_LEVEL_NOTICE, "settings");
|
||||
}
|
||||
return DEFAULT_SETTINGS as ObsidianLiveSyncSettings;
|
||||
});
|
||||
|
||||
// App lifecycle handlers
|
||||
this.serviceHub.appLifecycle.scheduleRestart.setHandler(() => {
|
||||
void (async () => {
|
||||
this.addLog("Restart requested", LOG_LEVEL_INFO, "app-lifecycle");
|
||||
await this.shutdown();
|
||||
await this.initialize();
|
||||
compatGlobal.setTimeout(() => {
|
||||
compatGlobal.location.reload();
|
||||
}, 1000);
|
||||
})();
|
||||
});
|
||||
|
||||
// Create LiveSync core
|
||||
this.core = new LiveSyncBaseCore<ServiceContext, never>(
|
||||
this.serviceHub,
|
||||
(core, serviceHub) => {
|
||||
const serviceModules = initialiseServiceModulesFSAPI(this.rootHandle, core, serviceHub);
|
||||
this.platformServiceModules = serviceModules;
|
||||
return serviceModules;
|
||||
},
|
||||
(core) => [
|
||||
// new ModuleObsidianEvents(this, core),
|
||||
// new ModuleObsidianSettingDialogue(this, core),
|
||||
// new ModuleObsidianMenu(core),
|
||||
// new ModuleObsidianSettingsAsMarkdown(core),
|
||||
// new ModuleLog(this, core),
|
||||
// new ModuleObsidianDocumentHistory(this, core),
|
||||
// new ModuleInteractiveConflictResolver(this, core),
|
||||
// new ModuleObsidianGlobalHistory(this, core),
|
||||
// new ModuleDev(this, core),
|
||||
// new ModuleReplicateTest(this, core),
|
||||
// new ModuleIntegratedTest(this, core),
|
||||
// new ModuleReplicatorP2P(core), // Register P2P replicator for CLI (useP2PReplicator is not used here)
|
||||
new SetupManager(core),
|
||||
],
|
||||
() => [] as never[], // No add-ons
|
||||
(core) => {
|
||||
useOfflineScanner(core);
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
const replicator = useP2PReplicatorFeature(core);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
useSetupManagerHandlersFeature(core, setupManager);
|
||||
useSetupURIFeature(core);
|
||||
}
|
||||
);
|
||||
|
||||
// Start the core
|
||||
await this.start();
|
||||
row.append(info, useButton);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
|
||||
private async saveSettingsToFile(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
// Create .livesync directory if it does not exist
|
||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR, { create: true });
|
||||
return items;
|
||||
}
|
||||
|
||||
// Create/overwrite settings.json
|
||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(JSON.stringify(data, null, 2));
|
||||
await writable.close();
|
||||
async function startWithHandle(handle: FileSystemDirectoryHandle): Promise<void> {
|
||||
setStatus("info", `Starting LiveSync with vault: ${handle.name}`);
|
||||
const nextRuntime = new WebAppRuntime(handle, { reportStatus: setStatus });
|
||||
await nextRuntime.start();
|
||||
runtime = nextRuntime;
|
||||
|
||||
const selectorEl = getRequiredElement<HTMLDivElement>("vault-selector");
|
||||
selectorEl.classList.add("is-hidden");
|
||||
|
||||
const p2pControl = getRequiredElement<HTMLElement>("p2p-control");
|
||||
mount(WebAppP2P, {
|
||||
target: p2pControl,
|
||||
props: {
|
||||
runtime: nextRuntime,
|
||||
},
|
||||
});
|
||||
p2pControl.classList.remove("is-hidden");
|
||||
}
|
||||
|
||||
async function startWithHistory(item: VaultHistoryItem): Promise<void> {
|
||||
setBusyState(true);
|
||||
let handle: FileSystemDirectoryHandle;
|
||||
try {
|
||||
handle = await historyStore.activateHistoryItem(item);
|
||||
} catch (error) {
|
||||
setStatus("error", `Failed to open saved vault: ${String(error)}`);
|
||||
setBusyState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
private async loadSettingsFromFile(): Promise<Partial<ObsidianLiveSyncSettings> | null> {
|
||||
try {
|
||||
const livesyncDir = await this.rootHandle.getDirectoryHandle(SETTINGS_DIR);
|
||||
const fileHandle = await livesyncDir.getFileHandle(SETTINGS_FILE);
|
||||
const file = await fileHandle.getFile();
|
||||
const text = await file.text();
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("The WebApp settings file does not contain an object");
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async start() {
|
||||
if (!this.core) {
|
||||
throw new Error("Core not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
this.addLog("Initialising LiveSync...", LOG_LEVEL_INFO, "start");
|
||||
|
||||
const loadResult = await this.core.services.control.onLoad();
|
||||
if (!loadResult) {
|
||||
this.addLog("Failed to initialise LiveSync", LOG_LEVEL_NOTICE, "start");
|
||||
this.showError("Failed to initialize LiveSync");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.core.services.control.onReady();
|
||||
|
||||
this.addLog("LiveSync is running", LOG_LEVEL_INFO, "ready");
|
||||
|
||||
// Check if configured
|
||||
const settings = this.core.services.setting.currentSettings();
|
||||
if (!settings.isConfigured) {
|
||||
this.addLog("LiveSync is not configured yet", LOG_LEVEL_NOTICE, "configuration");
|
||||
this.showWarning("Please configure CouchDB connection in settings");
|
||||
} else {
|
||||
this.addLog("LiveSync is configured and ready", LOG_LEVEL_INFO, "configuration");
|
||||
this.addLog(`Database: ${settings.couchDB_DBNAME}`, LOG_LEVEL_VERBOSE, "configuration");
|
||||
this.showSuccess("LiveSync is ready!");
|
||||
}
|
||||
|
||||
// Scan the directory to populate file cache
|
||||
const fileAccess = this.platformServiceModules?.vaultAccess;
|
||||
if (fileAccess) {
|
||||
this.addLog("Scanning vault directory...", LOG_LEVEL_VERBOSE, "scan");
|
||||
await fileAccess.fsapiAdapter.scanDirectory();
|
||||
const files = await fileAccess.fsapiAdapter.getFiles();
|
||||
this.addLog(`Found ${files.length} files`, LOG_LEVEL_VERBOSE, "scan");
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLog(`Failed to start: ${String(error)}`, LOG_LEVEL_NOTICE, "start");
|
||||
this.showError(`Failed to start: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this.core) {
|
||||
this.addLog("Shutting down...", LOG_LEVEL_INFO, "shutdown");
|
||||
|
||||
// Stop file watching
|
||||
const storageEventManager = this.platformServiceModules?.storageEventManager;
|
||||
if (storageEventManager) {
|
||||
await storageEventManager.cleanup();
|
||||
}
|
||||
|
||||
await this.core.services.control.onUnload();
|
||||
this.platformServiceModules = null;
|
||||
this.addLog("Shutdown complete", LOG_LEVEL_INFO, "shutdown");
|
||||
}
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
const statusEl = _activeDocument.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = `Error: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
private showWarning(message: string) {
|
||||
const statusEl = _activeDocument.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.className = "warning";
|
||||
statusEl.textContent = `Warning: ${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
private showSuccess(message: string) {
|
||||
const statusEl = _activeDocument.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.className = "success";
|
||||
statusEl.textContent = message;
|
||||
}
|
||||
try {
|
||||
await startWithHandle(handle);
|
||||
} catch (error) {
|
||||
setStatus("error", `Failed to start LiveSync: ${String(error)}`);
|
||||
setBusyState(false);
|
||||
}
|
||||
}
|
||||
|
||||
export { LiveSyncWebApp };
|
||||
async function startWithNewPicker(): Promise<void> {
|
||||
setBusyState(true);
|
||||
let handle: FileSystemDirectoryHandle;
|
||||
try {
|
||||
handle = await historyStore.pickNewVault();
|
||||
} catch (error) {
|
||||
setStatus("warning", `Vault selection was cancelled or failed: ${String(error)}`);
|
||||
setBusyState(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startWithHandle(handle);
|
||||
} catch (error) {
|
||||
setStatus("error", `Failed to start LiveSync: ${String(error)}`);
|
||||
setBusyState(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function initialiseVaultSelector(): Promise<void> {
|
||||
setStatus("info", "Select a vault folder to start LiveSync.");
|
||||
|
||||
const pickNewBtn = getRequiredElement<HTMLButtonElement>("pick-new-vault");
|
||||
pickNewBtn.addEventListener("click", () => {
|
||||
void startWithNewPicker();
|
||||
});
|
||||
|
||||
await renderHistoryList();
|
||||
}
|
||||
|
||||
compatGlobal.addEventListener("load", () => {
|
||||
initialiseVaultSelector().catch((error) => {
|
||||
setStatus("error", `Initialisation failed: ${String(error)}`);
|
||||
});
|
||||
});
|
||||
|
||||
compatGlobal.addEventListener("beforeunload", () => {
|
||||
void runtime?.shutdown();
|
||||
});
|
||||
|
||||
(compatGlobal as LiveSyncWebAppGlobal).livesyncApp = {
|
||||
getRuntime: () => runtime,
|
||||
historyStore,
|
||||
};
|
||||
|
||||
@@ -74,7 +74,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, 1);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("Failed to open the WebApp snapshot database"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
@@ -95,7 +96,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(snapshot, this.snapshotKey);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("Failed to save the WebApp storage-event snapshot"));
|
||||
});
|
||||
|
||||
db.close();
|
||||
@@ -114,7 +116,8 @@ class FSAPIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
const request = store.get(this.snapshotKey);
|
||||
request.onsuccess = () =>
|
||||
resolve((request.result as (FileEventItem | FileEventItemSentinel)[] | undefined) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("Failed to load the WebApp storage-event snapshot"));
|
||||
});
|
||||
|
||||
db.close();
|
||||
@@ -191,11 +194,15 @@ class FSAPIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
) {}
|
||||
|
||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||
// Use FileSystemObserver if available (Chrome 124+)
|
||||
// Use FileSystemObserver when the browser provides it.
|
||||
const FileSystemObserver = (compatGlobal as GlobalWithFileSystemObserver).FileSystemObserver;
|
||||
if (!FileSystemObserver) {
|
||||
this.addLog("FileSystemObserver is not available; file watching is disabled", LOG_LEVEL_INFO, "fsapi-watch");
|
||||
this.addLog("Chrome 124 or later supports real-time file watching", LOG_LEVEL_INFO, "fsapi-watch");
|
||||
this.addLog(
|
||||
"FileSystemObserver is not available; file watching is disabled",
|
||||
LOG_LEVEL_INFO,
|
||||
"fsapi-watch"
|
||||
);
|
||||
this.addLog("Use 'Scan local files' after external changes", LOG_LEVEL_INFO, "fsapi-watch");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileURLToPath, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import * as ts from "typescript";
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const dependencyFacadePath = path.resolve(repositoryRoot, "src/deps.ts");
|
||||
const obsidianMockPath = path.resolve(repositoryRoot, "src/apps/webapp/obsidianMock.ts");
|
||||
|
||||
function parseSourceFile(filePath: string): ts.SourceFile {
|
||||
const source = ts.sys.readFile(filePath);
|
||||
if (source === undefined) throw new Error(`Could not read ${filePath}`);
|
||||
return ts.createSourceFile(filePath, source, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
|
||||
}
|
||||
|
||||
function hasExportModifier(statement: ts.Statement): boolean {
|
||||
if (!ts.canHaveModifiers(statement)) return false;
|
||||
return ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
||||
}
|
||||
|
||||
function addBindingNames(name: ts.BindingName, names: Set<string>): void {
|
||||
if (ts.isIdentifier(name)) {
|
||||
names.add(name.text);
|
||||
return;
|
||||
}
|
||||
for (const element of name.elements) {
|
||||
if (!ts.isOmittedExpression(element)) addBindingNames(element.name, names);
|
||||
}
|
||||
}
|
||||
|
||||
describe("Webapp Obsidian mock exports", () => {
|
||||
it("provides every value re-exported from Obsidian", () => {
|
||||
const dependencyFacade = parseSourceFile(dependencyFacadePath);
|
||||
const obsidianMock = parseSourceFile(obsidianMockPath);
|
||||
|
||||
const requiredExports = new Set<string>();
|
||||
for (const statement of dependencyFacade.statements) {
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
statement.moduleSpecifier &&
|
||||
ts.isStringLiteral(statement.moduleSpecifier) &&
|
||||
statement.moduleSpecifier.text === "obsidian" &&
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause) &&
|
||||
!statement.isTypeOnly
|
||||
) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
requiredExports.add((element.propertyName ?? element.name).text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const availableExports = new Set<string>();
|
||||
for (const statement of obsidianMock.statements) {
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
!statement.isTypeOnly &&
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause)
|
||||
) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) availableExports.add(element.name.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!hasExportModifier(statement)) continue;
|
||||
if (
|
||||
(ts.isClassDeclaration(statement) ||
|
||||
ts.isFunctionDeclaration(statement) ||
|
||||
ts.isEnumDeclaration(statement)) &&
|
||||
statement.name
|
||||
) {
|
||||
availableExports.add(statement.name.text);
|
||||
} else if (ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
addBindingNames(declaration.name, availableExports);
|
||||
}
|
||||
}
|
||||
}
|
||||
const missingExports = [...requiredExports].filter((name) => !availableExports.has(name)).sort();
|
||||
|
||||
expect(missingExports).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.0-rc.1-webapp",
|
||||
"version": "1.0.2-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
@@ -9,10 +9,13 @@
|
||||
"build": "vite build",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-webapp ../../..",
|
||||
"run:docker": "docker run -p 8002:80 livesync-webapp",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webapp",
|
||||
"pretest:browser": "npm run build",
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -68,7 +68,8 @@ export class VaultHistoryStore {
|
||||
private async openHandleDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(HANDLE_DB_NAME, 1);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("Could not open the WebApp Vault history database"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
@@ -93,7 +94,7 @@ export class VaultHistoryStore {
|
||||
private async requestAsPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onerror = () => reject(request.error ?? new Error("The WebApp Vault history request failed"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "../../"),
|
||||
obsidian: path.resolve(__dirname, "./obsidianMock.ts"),
|
||||
},
|
||||
},
|
||||
base: "./",
|
||||
@@ -39,7 +38,6 @@ export default defineConfig({
|
||||
MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestVersion || "0.0.0"),
|
||||
PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageVersion || "0.0.0"),
|
||||
global: "globalThis",
|
||||
hostPlatform: JSON.stringify(process.platform || "linux"),
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
|
||||
@@ -31,7 +31,7 @@ body {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 40px;
|
||||
max-width: 700px;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -161,10 +161,19 @@ button:disabled {
|
||||
}
|
||||
|
||||
.empty-note.is-hidden,
|
||||
.vault-selector.is-hidden {
|
||||
.vault-selector.is-hidden,
|
||||
.p2p-control.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.p2p-control {
|
||||
margin-bottom: 22px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e6e9f2;
|
||||
border-radius: 8px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
@@ -397,4 +406,4 @@ popup {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-38
@@ -1,45 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Self-hosted LiveSync WebApp</title>
|
||||
<link rel="stylesheet" href="./webapp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Self-hosted LiveSync on Web</h1>
|
||||
<p class="subtitle">Browser-based Self-hosted LiveSync using FileSystem API</p>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Self-hosted LiveSync WebApp</title>
|
||||
<link rel="stylesheet" href="./webapp.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Self-hosted LiveSync on Web</h1>
|
||||
<p class="subtitle">Experimental browser proof of concept using the File System Access API</p>
|
||||
|
||||
<div id="status" class="info">Initialising...</div>
|
||||
<div id="status" class="info">Initialising...</div>
|
||||
|
||||
<div id="vault-selector" class="vault-selector">
|
||||
<h2>Select Vault Folder</h2>
|
||||
<p>Open a vault you already used, or pick a new folder.</p>
|
||||
<div id="vault-selector" class="vault-selector">
|
||||
<h2>Select Vault Folder</h2>
|
||||
<p>Open a vault you already used, or pick a new folder.</p>
|
||||
|
||||
<div id="vault-history-list" class="vault-list"></div>
|
||||
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
|
||||
<button id="pick-new-vault" type="button">Choose new vault folder</button>
|
||||
<div id="vault-history-list" class="vault-list"></div>
|
||||
<p id="vault-history-empty" class="empty-note">No saved vaults yet.</p>
|
||||
<button id="pick-new-vault" type="button">Choose new vault folder</button>
|
||||
</div>
|
||||
|
||||
<section id="p2p-control" class="p2p-control is-hidden"></section>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>How to Use</h2>
|
||||
<ul>
|
||||
<li>Select the local Vault root and grant access.</li>
|
||||
<li>Create or edit <code>.livesync/settings.json</code> in that Vault.</li>
|
||||
<li>Reload this page to apply the settings.</li>
|
||||
<li>Use the P2P controls only as an optional secondary connection.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
Powered by
|
||||
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>How to Use</h2>
|
||||
<ul>
|
||||
<li>Select a vault folder and grant permission</li>
|
||||
<li>Create <code>.livesync/settings.json</code> in your vault folder</li>
|
||||
<li>Or use Setup-URI to apply settings</li>
|
||||
<li>Your files will be synced after "replicate now"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
Powered by
|
||||
<a href="https://github.com/vrtmrz/obsidian-livesync" target="_blank">Self-hosted LiveSync</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./bootstrap.ts"></script>
|
||||
</body>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+71
-18
@@ -1,37 +1,90 @@
|
||||
# A pseudo client for Self-hosted LiveSync Peer-to-Peer Sync mode
|
||||
# Self-hosted LiveSync WebPeer
|
||||
|
||||
## What is it for?
|
||||
WebPeer is an experimental, browser-hosted, P2P-only Self-hosted LiveSync peer. It can receive database changes from one peer and provide them to another without materialising ordinary Vault files.
|
||||
|
||||
This is a pseudo client for the Self-hosted LiveSync Peer-to-Peer Sync mode. It is a simple pure-client-side web-application that can be connected to the Self-hosted LiveSync in peer-to-peer.
|
||||
It stores LiveSync metadata and chunks in an origin-scoped local database, but it does not present them as a Vault. This makes it useful as a temporary browser peer or transfer bridge. It is not a durable replacement for CouchDB, a backup, or an always-on server.
|
||||
|
||||
As long as you have a browser, it starts up, so if you leave it opened some device, it can replace your existing remote servers such as CouchDB.
|
||||
## Requirements
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Of course, it has not been fully tested. Rather, it was created to be tested.
|
||||
WebPeer requires:
|
||||
|
||||
This pseudo client actually receives the data from other devices, and sends if some device requests it. However, it does not store **files** in the local storage. If you want to purge the data, please purge the browser's cache and indexedDB, local storage, etc.
|
||||
- a secure context, such as HTTPS or `localhost`;
|
||||
- IndexedDB, WebRTC, WebSocket, and Web Crypto support;
|
||||
- access to the configured signalling relay; and
|
||||
- the same Group ID, passphrase, and compatible P2P settings as the other peers.
|
||||
|
||||
## How to use it?
|
||||
TURN is optional and is needed only when a direct WebRTC connection cannot be established. The signalling relay is required for peer discovery, but it does not store or transfer Vault contents.
|
||||
|
||||
We can build the application from the repository root by running the following command:
|
||||
## Use
|
||||
|
||||
Serve `src/apps/webpeer/dist/` over HTTPS, or from `localhost`, then open `index.html`.
|
||||
|
||||
1. Enable the P2P replicator.
|
||||
2. Enter the signalling relay, Group ID, passphrase, and a unique device name.
|
||||
3. Select **Save and Apply**.
|
||||
4. Select **Connect** and wait for the intended peers to appear.
|
||||
5. Use the peer actions to fetch, send, watch, or configure automatic synchronisation as required.
|
||||
|
||||
**Start change-broadcasting on Connect** allows watching peers to notice changes and fetch them. It does not send Vault data through the signalling relay. Optional TURN settings are available separately.
|
||||
|
||||
Keep the page open while WebPeer is expected to announce or transfer changes.
|
||||
|
||||
## Storage and lifecycle
|
||||
|
||||
WebPeer stores its settings, metadata, and chunks in storage belonging to the page origin. Consequently:
|
||||
|
||||
- changing the scheme, host, or port creates a different WebPeer state;
|
||||
- clearing site data removes the local database and settings;
|
||||
- browser storage eviction can remove the state; and
|
||||
- page suspension, tab closure, device sleep, and network policy can interrupt P2P activity.
|
||||
|
||||
Use a separately deployed origin when isolation from the public Pages deployment is required. Do not treat WebPeer browser storage as the only copy of any data.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No peers appear
|
||||
|
||||
- Confirm that every peer uses the same signalling relay, Group ID, and passphrase.
|
||||
- Confirm that each peer has a distinct device name.
|
||||
- Confirm that P2P is enabled and that the signalling connection reports **Connected**.
|
||||
- Check whether the browser or network blocks the relay WebSocket.
|
||||
|
||||
### Peers connect but changes do not transfer
|
||||
|
||||
- Confirm which peer should fetch and which should send.
|
||||
- Start broadcasting on the source when another peer is watching it.
|
||||
- Use the explicit fetch or send action to test one direction.
|
||||
- Confirm that the peers use compatible Self-hosted LiveSync versions and P2P settings.
|
||||
|
||||
### Saved settings or data appear to be missing
|
||||
|
||||
- Confirm that the page is using the same origin as before.
|
||||
- Check whether site data was cleared or evicted.
|
||||
- Check the status line and WebPeer log for the first reported error.
|
||||
|
||||
For more detail about relay, TURN, announcing, and following behaviour, see the [P2P guide](../../../docs/p2p.md).
|
||||
|
||||
## Development
|
||||
|
||||
Build it from the repository root:
|
||||
|
||||
```bash
|
||||
npm run build -w webpeer
|
||||
npm run build --workspace webpeer
|
||||
```
|
||||
|
||||
Or from the package directory:
|
||||
The app-owned unit and Chromium tests can be run with:
|
||||
|
||||
```bash
|
||||
cd src/apps/webpeer
|
||||
npm run build
|
||||
npm run test:unit --workspace webpeer
|
||||
npm run test:browser --workspace webpeer
|
||||
```
|
||||
|
||||
Then, open `dist/index.html` in the browser. It can be configured in the same way as Self-hosted LiveSync (the same components are used[^1]).
|
||||
The unit tests are stored in `test/apps/webpeer/`, outside the Community Review source boundary.
|
||||
|
||||
## Some notes
|
||||
## Composition
|
||||
|
||||
I will launch this application in the github pages later, so will be able to use it without building it. However, that shares the origin. Hence, the application that your have built and deployed would be more secure.
|
||||
`WebPeerRuntime.ts` owns the browser service composition, local database lifecycle, P2P replicator, and peer actions. `WebPeerPersistence.ts` owns origin-scoped settings persistence, while the shared P2P pane supplies the connection and peer controls.
|
||||
|
||||
## Licence
|
||||
|
||||
[^1]: Congrats! I made it modular. Finally...
|
||||
|
||||
The same licence as the main Self-hosted LiveSync project applies.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.0-rc.1-webpeer",
|
||||
"version": "1.0.2-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,10 +9,13 @@
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-webpeer ../../..",
|
||||
"run:docker": "docker run -p 8001:80 livesync-webpeer",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||
"test:unit": "npm --prefix ../../.. run test:unit -- test/apps/webpeer",
|
||||
"pretest:browser": "npm run build",
|
||||
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.51"
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const logs = writable([] as string[]);
|
||||
|
||||
let _logs = [] as string[];
|
||||
|
||||
const maxLines = 10000;
|
||||
setGlobalLogFunction((msg, level) => {
|
||||
const msgstr = typeof msg === "string" ? msg : JSON.stringify(msg);
|
||||
const strLog = `${new Date().toISOString()}\u2001${msgstr}`;
|
||||
_logs.push(strLog);
|
||||
if (_logs.length > maxLines) {
|
||||
_logs = _logs.slice(_logs.length - maxLines);
|
||||
}
|
||||
logs.set(_logs);
|
||||
});
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE;
|
||||
|
||||
export const storeP2PStatusLine = writable("");
|
||||
@@ -1,341 +0,0 @@
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import {
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import { LOG_LEVEL_NOTICE, Logger, type LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import {
|
||||
EVENT_P2P_PEER_SHOW_EXTRA_MENU,
|
||||
type PeerStatus,
|
||||
type PluginShim,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { useP2PReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorCore";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { P2PReplicatorBase } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorBase";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { Menu } from "@/apps/browser/BrowserMenu";
|
||||
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
|
||||
import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService";
|
||||
import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService";
|
||||
import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
|
||||
function addToList(item: string, list: string) {
|
||||
return unique(
|
||||
list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.concat(item)
|
||||
.filter((p) => p)
|
||||
).join(",");
|
||||
}
|
||||
function removeFromList(item: string, list: string) {
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((p) => p !== item)
|
||||
.filter((p) => p)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export class P2PReplicatorShim implements P2PReplicatorBase {
|
||||
storeP2PStatusLine = reactiveSource("");
|
||||
plugin!: PluginShim;
|
||||
confirm!: Confirm;
|
||||
db?: PouchDB.Database<EntryDoc>;
|
||||
services: InjectableServiceHub<ServiceContext>;
|
||||
|
||||
getDB() {
|
||||
if (!this.db) {
|
||||
throw new Error("DB not initialized");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
_simpleStore!: SimpleStore<unknown>;
|
||||
|
||||
async closeDB() {
|
||||
if (this.db) {
|
||||
await this.db.close();
|
||||
this.db = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _liveSyncReplicator?: LiveSyncTrysteroReplicator;
|
||||
p2pLogCollector!: P2PLogCollector;
|
||||
|
||||
private _initP2PReplicator() {
|
||||
const {
|
||||
replicator,
|
||||
p2pLogCollector,
|
||||
storeP2PStatusLine: p2pStatusLine,
|
||||
} = useP2PReplicator({ services: this.services } as unknown as Parameters<typeof useP2PReplicator>[0]);
|
||||
this._liveSyncReplicator = replicator;
|
||||
this.p2pLogCollector = p2pLogCollector;
|
||||
p2pLogCollector.p2pReplicationLine.onChanged((line) => {
|
||||
p2pStatusLine.value = line.value;
|
||||
});
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const browserServiceHub = createLiveSyncBrowserServiceHub<ServiceContext>();
|
||||
this.services = browserServiceHub;
|
||||
|
||||
(this.services.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
|
||||
() => "p2p-livesync-web-peer"
|
||||
);
|
||||
const repStore = SimpleStoreIDBv2.open<unknown>("p2p-livesync-web-peer");
|
||||
this._simpleStore = repStore;
|
||||
let _settings = { ...P2P_DEFAULT_SETTINGS, additionalSuffixOfDatabaseName: "" } as ObsidianLiveSyncSettings;
|
||||
this.services.setting.settings = _settings;
|
||||
(this.services.setting as InjectableSettingService<ServiceContext>).saveData.setHandler(async (data) => {
|
||||
await repStore.set("settings", data);
|
||||
this.services.context.events.emitEvent(EVENT_SETTING_SAVED, data);
|
||||
});
|
||||
(this.services.setting as InjectableSettingService<ServiceContext>).loadData.setHandler(async () => {
|
||||
const settings = { ..._settings, ...((await repStore.get("settings")) as ObsidianLiveSyncSettings) };
|
||||
return settings;
|
||||
});
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.services.setting.currentSettings() as P2PSyncSetting;
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.confirm = this.services.UI.confirm;
|
||||
|
||||
if (this.db) {
|
||||
try {
|
||||
await this.closeDB();
|
||||
} catch (ex) {
|
||||
Logger("Error closing db", LOG_LEVEL_VERBOSE);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
await this.services.setting.loadSettings();
|
||||
this.plugin = {
|
||||
services: this.services,
|
||||
core: {
|
||||
services: this.services,
|
||||
},
|
||||
};
|
||||
const database_name = this.settings.P2P_AppID + "-" + this.settings.P2P_roomID + "p2p-livesync-web-peer";
|
||||
this.db = new PouchDB<EntryDoc>(database_name);
|
||||
|
||||
this._initP2PReplicator();
|
||||
|
||||
compatGlobal.setTimeout(() => {
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
void this.open();
|
||||
}
|
||||
}, 1000);
|
||||
return this;
|
||||
}
|
||||
|
||||
_log(msg: unknown, level?: LOG_LEVEL): void {
|
||||
Logger(msg, level);
|
||||
}
|
||||
_notice(msg: string, key?: string): void {
|
||||
Logger(msg, LOG_LEVEL_NOTICE, key);
|
||||
}
|
||||
getSettings(): P2PSyncSetting {
|
||||
return this.settings;
|
||||
}
|
||||
simpleStore(): SimpleStore<unknown> {
|
||||
return this._simpleStore;
|
||||
}
|
||||
handleReplicatedDocuments(_docs: EntryDoc[]): Promise<boolean> {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
getConfig(key: string) {
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const dbKey = `${vaultName}-${key}`;
|
||||
return compatGlobal.localStorage.getItem(dbKey);
|
||||
}
|
||||
setConfig(key: string, value: string) {
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const dbKey = `${vaultName}-${key}`;
|
||||
compatGlobal.localStorage.setItem(dbKey, value);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.getConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? this.plugin.services.vault.getVaultName();
|
||||
}
|
||||
|
||||
m?: Menu;
|
||||
afterConstructor(): void {
|
||||
this.services.context.events.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => {
|
||||
if (this.m) {
|
||||
this.m.hide();
|
||||
}
|
||||
this.m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.watchOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnReplicationCommand");
|
||||
})
|
||||
.setIcon(mark);
|
||||
});
|
||||
void this.m.showAtPosition({ x: event.x, y: event.y });
|
||||
});
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this._liveSyncReplicator?.open();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this._liveSyncReplicator?.close();
|
||||
}
|
||||
|
||||
enableBroadcastCastings() {
|
||||
return this._liveSyncReplicator?.enableBroadcastChanges();
|
||||
}
|
||||
disableBroadcastCastings() {
|
||||
return this._liveSyncReplicator?.disableBroadcastChanges();
|
||||
}
|
||||
|
||||
enableBroadcastChanges() {
|
||||
return this._liveSyncReplicator?.enableBroadcastChanges();
|
||||
}
|
||||
|
||||
disableBroadcastChanges() {
|
||||
return this._liveSyncReplicator?.disableBroadcastChanges();
|
||||
}
|
||||
|
||||
async makeDecision(decision: Parameters<LiveSyncTrysteroReplicator["makeDecision"]>[0]): Promise<void> {
|
||||
await this._liveSyncReplicator?.makeDecision(decision);
|
||||
}
|
||||
|
||||
async revokeDecision(decision: Parameters<LiveSyncTrysteroReplicator["revokeDecision"]>[0]): Promise<void> {
|
||||
await this._liveSyncReplicator?.revokeDecision(decision);
|
||||
}
|
||||
|
||||
watchPeer(peerId: string): void {
|
||||
this._liveSyncReplicator?.watchPeer(peerId);
|
||||
}
|
||||
|
||||
unwatchPeer(peerId: string): void {
|
||||
this._liveSyncReplicator?.unwatchPeer(peerId);
|
||||
}
|
||||
|
||||
async sync(peerId: string, showNotice?: boolean): Promise<unknown> {
|
||||
return await this._liveSyncReplicator?.sync(peerId, showNotice);
|
||||
}
|
||||
|
||||
get replicator() {
|
||||
return this._liveSyncReplicator;
|
||||
}
|
||||
|
||||
async replicateFrom(peer: PeerStatus) {
|
||||
const r = this._liveSyncReplicator;
|
||||
if (!r) return;
|
||||
await r.replicateFrom(peer.peerId);
|
||||
}
|
||||
|
||||
async replicateTo(peer: PeerStatus) {
|
||||
await this._liveSyncReplicator?.requestSynchroniseToPeer(peer.peerId);
|
||||
}
|
||||
|
||||
async getRemoteConfig(peer: PeerStatus) {
|
||||
Logger(
|
||||
`Requesting remote config for ${peer.name}. Please input the passphrase on the remote device`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
const remoteConfig = await this._liveSyncReplicator?.getRemoteConfig(peer.peerId);
|
||||
if (remoteConfig) {
|
||||
Logger(`Remote config for ${peer.name} is retrieved successfully`);
|
||||
const DROP = "Yes, and drop local database";
|
||||
const KEEP = "Yes, but keep local database";
|
||||
const CANCEL = "No, cancel";
|
||||
const yn = await this.confirm.askSelectStringDialogue(
|
||||
`Do you really want to apply the remote config? This will overwrite your current config immediately and restart.
|
||||
And you can also drop the local database to rebuild from the remote device.`,
|
||||
[DROP, KEEP, CANCEL] as const,
|
||||
{
|
||||
defaultAction: CANCEL,
|
||||
title: "Apply Remote Config ",
|
||||
}
|
||||
);
|
||||
if (yn === DROP || yn === KEEP) {
|
||||
if (yn === DROP) {
|
||||
if (remoteConfig.remoteType !== REMOTE_P2P) {
|
||||
const yn2 = await this.confirm.askYesNoDialog(
|
||||
`Do you want to set the remote type to "P2P Sync" to rebuild by "P2P replication"?`,
|
||||
{ title: "Rebuild from remote device" }
|
||||
);
|
||||
if (yn2 === "yes") {
|
||||
remoteConfig.remoteType = REMOTE_P2P;
|
||||
remoteConfig.P2P_RebuildFrom = peer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.services.setting.applyExternalSettings(remoteConfig, true);
|
||||
if (yn !== DROP) {
|
||||
this.plugin.core.services.appLifecycle.scheduleRestart();
|
||||
}
|
||||
} else {
|
||||
Logger(`Cancelled\nRemote config for ${peer.name} is not applied`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
} else {
|
||||
Logger(`Cannot retrieve remote config for ${peer.peerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleProp(peer: PeerStatus, prop: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand") {
|
||||
const settingMap = {
|
||||
syncOnConnect: "P2P_AutoSyncPeers",
|
||||
watchOnConnect: "P2P_AutoWatchPeers",
|
||||
syncOnReplicationCommand: "P2P_SyncOnReplication",
|
||||
} as const;
|
||||
|
||||
const targetSetting = settingMap[prop];
|
||||
const currentSettingAll = this.plugin.core.services.setting.currentSettings();
|
||||
const currentSetting = {
|
||||
[targetSetting]: currentSettingAll ? currentSettingAll[targetSetting] : "",
|
||||
};
|
||||
if (peer[prop]) {
|
||||
currentSetting[targetSetting] = removeFromList(peer.name, currentSetting[targetSetting]);
|
||||
} else {
|
||||
currentSetting[targetSetting] = addToList(peer.name, currentSetting[targetSetting]);
|
||||
}
|
||||
await this.plugin.core.services.setting.applyPartial(currentSetting, true);
|
||||
}
|
||||
}
|
||||
|
||||
export const cmdSyncShim = new P2PReplicatorShim();
|
||||
@@ -1,34 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { storeP2PStatusLine, logs } from "./CommandsShim";
|
||||
import { logs } from "./WebPeerLogs";
|
||||
import BrowserP2PTransportSettings from "@/apps/browser/BrowserP2PTransportSettings.svelte";
|
||||
import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { cmdSyncShim } from "./P2PReplicatorShim";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { WebPeerRuntime } from "./WebPeerRuntime";
|
||||
|
||||
let synchronised = $state(cmdSyncShim.init());
|
||||
const runtime = new WebPeerRuntime();
|
||||
const synchronised = runtime.start();
|
||||
let elP: HTMLDivElement;
|
||||
let statusLine = $state(runtime.statusLine.value);
|
||||
|
||||
onMount(() => {
|
||||
void synchronised.then((shim) => shim.services.context.events.emitEvent(EVENT_LAYOUT_READY));
|
||||
return () => {
|
||||
synchronised.then((e) => e.close());
|
||||
const onStatusLineChanged = (line: { readonly value: string }) => {
|
||||
statusLine = line.value;
|
||||
};
|
||||
runtime.statusLine.onChanged(onStatusLineChanged);
|
||||
const unsubscribeLogs = logs.subscribe(() => {
|
||||
void tick().then(() => elP?.scrollTo({ top: elP.scrollHeight }));
|
||||
});
|
||||
return () => {
|
||||
runtime.statusLine.offChanged(onStatusLineChanged);
|
||||
unsubscribeLogs();
|
||||
void runtime.shutdown();
|
||||
};
|
||||
});
|
||||
let elP: HTMLDivElement;
|
||||
logs.subscribe((log) => {
|
||||
tick().then(() => elP?.scrollTo({ top: elP.scrollHeight }));
|
||||
});
|
||||
let statusLine = $state("");
|
||||
storeP2PStatusLine.subscribe((status) => {
|
||||
statusLine = status;
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="control">
|
||||
{#await synchronised then cmdSync}
|
||||
<P2PReplicatorPane {cmdSync} core={cmdSync.plugin.core}></P2PReplicatorPane>
|
||||
{#await synchronised then activeRuntime}
|
||||
<BrowserP2PTransportSettings host={activeRuntime.paneHost} />
|
||||
<P2PReplicatorPane host={activeRuntime.paneHost}></P2PReplicatorPane>
|
||||
{:catch error}
|
||||
<p>{error.message}</p>
|
||||
<p>{error instanceof Error ? error.message : String(error)}</p>
|
||||
{/await}
|
||||
</div>
|
||||
<div class="log">
|
||||
|
||||
@@ -19,16 +19,16 @@
|
||||
|
||||
async function testMenu(event: MouseEvent) {
|
||||
const m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => {}))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => {}))
|
||||
.addItem((item) => item.setTitle("📥 Only fetch").onClick(() => {}))
|
||||
.addItem((item) => item.setTitle("📤 Only send").onClick(() => {}))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🔧 Get Configuration").onClick(async () => {});
|
||||
item.setTitle("🔧 Get configuration").onClick(async () => {});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = "checkmark";
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
item.setTitle("Toggle sync on connect")
|
||||
.onClick(async () => {
|
||||
// await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
@@ -36,7 +36,7 @@
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
item.setTitle("Toggle watch on connect")
|
||||
.onClick(async () => {
|
||||
// await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
@@ -44,7 +44,7 @@
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
item.setTitle("Toggle sync on `Replicate now` command")
|
||||
.onClick(async () => {})
|
||||
.setIcon(mark);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const logs = writable<string[]>([]);
|
||||
|
||||
let bufferedLogs: string[] = [];
|
||||
const maxLines = 10_000;
|
||||
|
||||
setGlobalLogFunction((message) => {
|
||||
const messageText = typeof message === "string" ? message : JSON.stringify(message);
|
||||
bufferedLogs.push(`${new Date().toISOString()}\u2001${messageText}`);
|
||||
if (bufferedLogs.length > maxLines) {
|
||||
bufferedLogs = bufferedLogs.slice(bufferedLogs.length - maxLines);
|
||||
}
|
||||
logs.set(bufferedLogs);
|
||||
});
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_VERBOSE;
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
|
||||
|
||||
import type { LiveSyncBrowserSettingsPersistence } from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
|
||||
export const WEBPEER_STORE_NAME = "p2p-livesync-web-peer";
|
||||
export const WEBPEER_SETTINGS_KEY = "settings";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Creates WebPeer-owned settings persistence without retaining legacy database names. */
|
||||
export function createWebPeerPersistence(
|
||||
store: SimpleStore<unknown> = SimpleStoreIDBv2.open<unknown>(WEBPEER_STORE_NAME)
|
||||
): {
|
||||
readonly store: SimpleStore<unknown>;
|
||||
readonly settings: LiveSyncBrowserSettingsPersistence;
|
||||
} {
|
||||
const settings: LiveSyncBrowserSettingsPersistence = {
|
||||
async load() {
|
||||
const savedSettings = await store.get(WEBPEER_SETTINGS_KEY);
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...P2P_DEFAULT_SETTINGS,
|
||||
additionalSuffixOfDatabaseName: "",
|
||||
suspendParseReplicationResult: true,
|
||||
...(isRecord(savedSettings) ? savedSettings : {}),
|
||||
remoteType: REMOTE_P2P,
|
||||
isConfigured: true,
|
||||
};
|
||||
},
|
||||
async save(currentSettings) {
|
||||
await store.set(WEBPEER_SETTINGS_KEY, currentSettings);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
settings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { type P2PSyncSetting, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import { ServiceContext, type LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
|
||||
import {
|
||||
createLiveSyncBrowserServiceHub,
|
||||
type LiveSyncBrowserServiceHub,
|
||||
} from "@/apps/browser/createLiveSyncBrowserServiceHub";
|
||||
import { Menu } from "@/apps/browser/BrowserMenu";
|
||||
import type { P2PReplicatorPaneHost } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneHost";
|
||||
import { translateLiveSyncMessage } from "@/common/translation";
|
||||
import { WEBPEER_STORE_NAME, createWebPeerPersistence } from "./WebPeerPersistence";
|
||||
|
||||
export interface WebPeerRuntimeOptions {
|
||||
context?: ServiceContext;
|
||||
store?: SimpleStore<unknown>;
|
||||
}
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
return unique(
|
||||
list
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.concat(item)
|
||||
.filter(Boolean)
|
||||
).join(",");
|
||||
}
|
||||
|
||||
function removeFromList(item: string, list: string): string {
|
||||
return list
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry !== item)
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export class WebPeerRuntime {
|
||||
readonly context: ServiceContext;
|
||||
readonly services: LiveSyncBrowserServiceHub<ServiceContext>;
|
||||
readonly p2p: UseP2PReplicatorResult;
|
||||
readonly p2pLogCollector: P2PLogCollector;
|
||||
readonly paneHost: P2PReplicatorPaneHost;
|
||||
|
||||
private menu?: Menu;
|
||||
private restartScheduled = false;
|
||||
private startPromise?: Promise<this>;
|
||||
private shutdownPromise?: Promise<void>;
|
||||
|
||||
constructor(options: WebPeerRuntimeOptions = {}) {
|
||||
const persistence = createWebPeerPersistence(options.store);
|
||||
this.context = options.context ?? new ServiceContext({ translate: translateLiveSyncMessage });
|
||||
this.services = createLiveSyncBrowserServiceHub<ServiceContext>({
|
||||
context: this.context,
|
||||
getSystemVaultName: () => WEBPEER_STORE_NAME,
|
||||
settings: persistence.settings,
|
||||
restart: {
|
||||
schedule: () => this.scheduleRestart(),
|
||||
perform: () => this.scheduleRestart(),
|
||||
ask: () => this.scheduleRestart(),
|
||||
isScheduled: () => this.restartScheduled,
|
||||
},
|
||||
});
|
||||
this.p2p = useP2PReplicatorFeature({
|
||||
services: this.services,
|
||||
serviceModules: {},
|
||||
});
|
||||
this.p2pLogCollector = new P2PLogCollector(this.events);
|
||||
this.paneHost = {
|
||||
services: this.services,
|
||||
p2p: this.p2p,
|
||||
showPeerMenu: (peer, event) => this.showPeerMenu(peer, event),
|
||||
};
|
||||
}
|
||||
|
||||
get events(): LiveSyncEventHub {
|
||||
return this.context.events;
|
||||
}
|
||||
|
||||
get currentReplicator(): LiveSyncTrysteroReplicator {
|
||||
return this.p2p.replicator;
|
||||
}
|
||||
|
||||
get settings(): P2PSyncSetting {
|
||||
return this.services.setting.currentSettings();
|
||||
}
|
||||
|
||||
get statusLine() {
|
||||
return this.p2pLogCollector.p2pReplicationLine;
|
||||
}
|
||||
|
||||
start(): Promise<this> {
|
||||
this.startPromise ??= this.startRuntime();
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
private async startRuntime(): Promise<this> {
|
||||
await this.services.setting.loadSettings();
|
||||
const opened = await this.services.database.openDatabase({
|
||||
replicator: this.services.replicator,
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
});
|
||||
if (!opened) {
|
||||
throw new Error("WebPeer local database could not be opened");
|
||||
}
|
||||
this.services.appLifecycle.markIsReady();
|
||||
this.events.emitEvent(EVENT_LAYOUT_READY);
|
||||
if (this.settings.P2P_AutoStart && this.settings.P2P_Enabled) {
|
||||
compatGlobal.setTimeout(() => void this.currentReplicator.open(), 100);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
shutdown(): Promise<void> {
|
||||
this.shutdownPromise ??= this.shutdownRuntime();
|
||||
return this.shutdownPromise;
|
||||
}
|
||||
|
||||
private async shutdownRuntime(): Promise<void> {
|
||||
this.menu?.hide();
|
||||
this.menu = undefined;
|
||||
if (!this.services.control.hasUnloaded()) {
|
||||
await this.services.control.onUnload();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRestart(): void {
|
||||
if (this.restartScheduled) {
|
||||
return;
|
||||
}
|
||||
this.restartScheduled = true;
|
||||
compatGlobal.setTimeout(() => compatGlobal.location.reload(), 0);
|
||||
}
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
this.menu?.hide();
|
||||
this.menu = new Menu()
|
||||
.addItem((item) =>
|
||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||
await this.currentReplicator.replicateFrom(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addItem((item) =>
|
||||
item.setTitle("📤 Only send").onClick(async () => {
|
||||
await this.currentReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
})
|
||||
)
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("Toggle sync on connect")
|
||||
.onClick(() => this.togglePeerSetting(peer, "syncOnConnect"))
|
||||
.setIcon(peer.syncOnConnect ? "checkmark" : null);
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle("Toggle watch on connect")
|
||||
.onClick(() => this.togglePeerSetting(peer, "watchOnConnect"))
|
||||
.setIcon(peer.watchOnConnect ? "checkmark" : null);
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle("Toggle sync on `Replicate now` command")
|
||||
.onClick(() => this.togglePeerSetting(peer, "syncOnReplicationCommand"))
|
||||
.setIcon(peer.syncOnReplicationCommand ? "checkmark" : null);
|
||||
});
|
||||
void this.menu.showAtPosition({ x: event.x, y: event.y });
|
||||
}
|
||||
|
||||
private async togglePeerSetting(
|
||||
peer: PeerStatus,
|
||||
property: "syncOnConnect" | "watchOnConnect" | "syncOnReplicationCommand"
|
||||
): Promise<void> {
|
||||
const settingMap = {
|
||||
syncOnConnect: "P2P_AutoSyncPeers",
|
||||
watchOnConnect: "P2P_AutoWatchPeers",
|
||||
syncOnReplicationCommand: "P2P_SyncOnReplication",
|
||||
} as const;
|
||||
const settingKey = settingMap[property];
|
||||
const currentValue = this.services.setting.currentSettings()[settingKey] ?? "";
|
||||
await this.services.setting.applyPartial(
|
||||
{
|
||||
[settingKey]: peer[property]
|
||||
? removeFromList(peer.name, currentValue)
|
||||
: addToList(peer.name, currentValue),
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
getDeviceName(): string {
|
||||
return this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,8 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable": "📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B": "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
"✅ Matches Vault": "✅ Matches Vault",
|
||||
@@ -130,8 +128,7 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
|
||||
"More actions for ${FILE}": "More actions for ${FILE}",
|
||||
"Show revision history": "Show revision history",
|
||||
"Store Vault file as a new local database document":
|
||||
"Store Vault file as a new local database document",
|
||||
"Store Vault file as a new local database document": "Store Vault file as a new local database document",
|
||||
"Copy database information": "Copy database information",
|
||||
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
|
||||
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
|
||||
@@ -140,8 +137,7 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
|
||||
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
|
||||
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
|
||||
"Inspect conflicts and file/database differences":
|
||||
"Inspect conflicts and file/database differences",
|
||||
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
|
||||
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
|
||||
"Begin inspection": "Begin inspection",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,78 @@
|
||||
{
|
||||
", 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.": ", 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.",
|
||||
"(Active)": "(Active)",
|
||||
"(BETA) Always overwrite with a newer file": "(BETA) Always overwrite with a newer file",
|
||||
"(Beta) Use ignore files": "(Beta) Use ignore files",
|
||||
"(Days passed, 0 to disable automatic-deletion)": "(Days passed, 0 to disable automatic-deletion)",
|
||||
"(e.g., after editing many files whilst offline)": "(e.g., after editing many files whilst offline)",
|
||||
"(e.g., immediately after restoring on another computer, or having recovered from a backup)": "(e.g., immediately after restoring on another computer, or having recovered from a backup)",
|
||||
"(e.g., setting up for the first time on a new smartphone, starting from a clean slate)": "(e.g., setting up for the first time on a new smartphone, starting from a clean slate)",
|
||||
"(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.": "(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.",
|
||||
"(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) 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.",
|
||||
"(Mega chars)": "(Mega chars)",
|
||||
"(Missing)": "(Missing)",
|
||||
"(Not recommended) If set, credentials will be stored in the file.": "(Not recommended) If set, credentials will be stored in the file.",
|
||||
"(Obsolete) Use an old adapter for compatibility": "(Obsolete) Use an old adapter for compatibility",
|
||||
"(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.": "(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.",
|
||||
"(RegExp) If this is set, any changes to local and remote files that match this will be skipped.": "(RegExp) If this is set, any changes to local and remote files that match this will be skipped.",
|
||||
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
|
||||
"(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.": "(Select this if you are configuring this device as the first synchronisation device.) This option is suitable if you are new to LiveSync and want to set it up from scratch.",
|
||||
"↑: Overwrite Remote": "↑: Overwrite Remote",
|
||||
"↓: Overwrite Local": "↓: Overwrite Local",
|
||||
"⇅: Use newer": "⇅: Use newer",
|
||||
"+1 week": "+1 week",
|
||||
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- The connected devices have been detected as follows:\n${devices}",
|
||||
"⚠️ Important Notice": "⚠️ Important Notice",
|
||||
"⚠️ Please Confirm the Following": "⚠️ Please Confirm the Following",
|
||||
"✔ SELECT": "✔ SELECT",
|
||||
"✔ SYNC": "✔ SYNC",
|
||||
"✔ WATCH": "✔ WATCH",
|
||||
"📡 Off": "📡 Off",
|
||||
"📡 On": "📡 On",
|
||||
"🔴 Disconnected": "🔴 Disconnected",
|
||||
"🕵️ Diag": "🕵️ Diag",
|
||||
"🗑 Delete": "🗑 Delete",
|
||||
"🟢 Connected": "🟢 Connected",
|
||||
"${count} issue(s) detected!": "${count} issue(s) detected!",
|
||||
"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.": "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.",
|
||||
"Accept": "Accept",
|
||||
"Accept in session": "Accept in session",
|
||||
"ACCEPTED": "ACCEPTED",
|
||||
"ACCEPTED (in session)": "ACCEPTED (in session)",
|
||||
"Access Key": "Access Key",
|
||||
"Access Key ID": "Access Key ID",
|
||||
"Action": "Action",
|
||||
"Activate": "Activate",
|
||||
"Active Remote Configuration": "Active Remote Configuration",
|
||||
"Add default patterns": "Add default patterns",
|
||||
"Add new connection": "Add new connection",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
"Advanced": "Advanced",
|
||||
"Advanced Settings": "Advanced Settings",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"After that, synchronise to a brand new vault on each other device with the new remote one by one.": "After that, synchronise to a brand new vault on each other device with the new remote one by one.",
|
||||
"All checks passed successfully!": "All checks passed successfully!",
|
||||
"All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": "All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.",
|
||||
"All the same or non-existent": "All the same or non-existent",
|
||||
"Allow in session": "Allow in session",
|
||||
"Allow permanently": "Allow permanently",
|
||||
"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.": "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.",
|
||||
"Always prompt merge conflicts": "Always prompt merge conflicts",
|
||||
"Analyse": "Analyse",
|
||||
"Analyse database usage": "Analyse database usage",
|
||||
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": "Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.",
|
||||
"Apply All Selected": "Apply All Selected",
|
||||
"Apply Latest Change if Conflicting": "Apply Latest Change if Conflicting",
|
||||
"Apply preset configuration": "Apply preset configuration",
|
||||
"Apply the settings": "Apply the settings",
|
||||
"Ask a passphrase at every launch": "Ask a passphrase at every launch",
|
||||
"Auto Connect": "Auto Connect",
|
||||
"Auto Start P2P Connection": "Auto Start P2P Connection",
|
||||
"Automatic": "Automatic",
|
||||
"Automatically Sync all files when opening Obsidian.": "Automatically Sync all files when opening Obsidian.",
|
||||
"Available Peers": "Available Peers",
|
||||
"Back": "Back",
|
||||
"Back to non-configured": "Back to non-configured",
|
||||
"Batch database update": "Batch database update",
|
||||
@@ -35,19 +80,31 @@
|
||||
"Batch size": "Batch size",
|
||||
"Batch size of on-demand fetching": "Batch size of on-demand fetching",
|
||||
"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.": "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.",
|
||||
"Broadcasting?": "Broadcasting?",
|
||||
"Bucket Name": "Bucket Name",
|
||||
"by resetting the remote, you will be informed on other devices.": "by resetting the remote, you will be informed on other devices.",
|
||||
"Cancel": "Cancel",
|
||||
"Cancel Garbage Collection": "Cancel Garbage Collection",
|
||||
"Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data.": "Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data.",
|
||||
"Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.": "Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.",
|
||||
"Check": "Check",
|
||||
"Check and convert non-path-obfuscated files": "Check and convert non-path-obfuscated files",
|
||||
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.",
|
||||
"Checking connection... Please wait.": "Checking connection... Please wait.",
|
||||
"Chunks": "Chunks",
|
||||
"Close": "Close",
|
||||
"Close & Disconnect": "Close & Disconnect",
|
||||
"Close this dialog": "Close this dialog",
|
||||
"Closed:": "Closed:",
|
||||
"cmdConfigSync.showCustomizationSync": "Show Customization sync",
|
||||
"Comma separated `.gitignore, .dockerignore`": "Comma separated `.gitignore, .dockerignore`",
|
||||
"Command": "Command",
|
||||
"Communicating": "Communicating",
|
||||
"Compaction in progress on remote database...": "Compaction in progress on remote database...",
|
||||
"Compaction on remote database completed successfully.": "Compaction on remote database completed successfully.",
|
||||
"Compaction on remote database failed.": "Compaction on remote database failed.",
|
||||
"Compaction on remote database timed out.": "Compaction on remote database timed out.",
|
||||
"Compare file": "Compare file",
|
||||
"Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.",
|
||||
"Compatibility (Conflict Behaviour)": "Compatibility (Conflict Behaviour)",
|
||||
"Compatibility (Database structure)": "Compatibility (Database structure)",
|
||||
@@ -56,48 +113,78 @@
|
||||
"Compatibility (Remote Database)": "Compatibility (Remote Database)",
|
||||
"Compatibility (Trouble addressed)": "Compatibility (Trouble addressed)",
|
||||
"Compute revisions for chunks": "Compute revisions for chunks",
|
||||
"Configuration": "Configuration",
|
||||
"Configuration Encryption": "Configuration Encryption",
|
||||
"Configure": "Configure",
|
||||
"Configure And Change Remote": "Configure And Change Remote",
|
||||
"Configure E2EE": "Configure E2EE",
|
||||
"Configure Remote": "Configure Remote",
|
||||
"Configure the same server information as your other devices again, manually, very advanced users only.": "Configure the same server information as your other devices again, manually, very advanced users only.",
|
||||
"Connect": "Connect",
|
||||
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})",
|
||||
"Connected:": "Connected:",
|
||||
"Connection Method": "Connection Method",
|
||||
"Connection Settings": "Connection Settings",
|
||||
"Connection:": "Connection:",
|
||||
"Continue anyway": "Continue anyway",
|
||||
"Continue to CouchDB setup": "Continue to CouchDB setup",
|
||||
"Continue to Peer-to-Peer only setup": "Continue to Peer-to-Peer only setup",
|
||||
"Continue to S3/MinIO/R2 setup": "Continue to S3/MinIO/R2 setup",
|
||||
"Copy": "Copy",
|
||||
"Copy Report to clipboard": "Copy Report to clipboard",
|
||||
"CouchDB Configuration": "CouchDB Configuration",
|
||||
"CouchDB Connection Tweak": "CouchDB Connection Tweak",
|
||||
"Create P2P remote": "Create P2P remote",
|
||||
"Cross-platform": "Cross-platform",
|
||||
"Current adapter: {adapter}": "Current adapter: {adapter}",
|
||||
"Custom Headers": "Custom Headers",
|
||||
"Customization Sync": "Customization Sync",
|
||||
"Customization Sync (Beta3)": "Customization Sync (Beta3)",
|
||||
"Data Compression": "Data Compression",
|
||||
"Data to Copy": "Data to Copy",
|
||||
"Database -> Storage": "Database -> Storage",
|
||||
"Database Adapter": "Database Adapter",
|
||||
"Database Name": "Database Name",
|
||||
"Database suffix": "Database suffix",
|
||||
"Date": "Date",
|
||||
"Default": "Default",
|
||||
"Delay conflict resolution of inactive files": "Delay conflict resolution of inactive files",
|
||||
"Delay merge conflict prompt for inactive files.": "Delay merge conflict prompt for inactive files.",
|
||||
"Delete": "Delete",
|
||||
"Delete all customization sync data": "Delete all customization sync data",
|
||||
"Delete all data on the remote server.": "Delete all data on the remote server.",
|
||||
"Delete All of": "Delete All of",
|
||||
"Delete local database to reset or uninstall Self-hosted LiveSync": "Delete local database to reset or uninstall Self-hosted LiveSync",
|
||||
"Delete old metadata of deleted files on start-up": "Delete old metadata of deleted files on start-up",
|
||||
"Delete Remote Configuration": "Delete Remote Configuration",
|
||||
"Delete remote configuration '{name}'?": "Delete remote configuration '{name}'?",
|
||||
"Delete remote configuration '${name}'?": "Delete remote configuration '${name}'?",
|
||||
"DENIED": "DENIED",
|
||||
"DENIED (in session)": "DENIED (in session)",
|
||||
"Deny": "Deny",
|
||||
"Deny in session": "Deny in session",
|
||||
"Deny permanently": "Deny permanently",
|
||||
"Deselect all": "Deselect all",
|
||||
"desktop": "desktop",
|
||||
"Detected Peers": "Detected Peers",
|
||||
"Developer": "Developer",
|
||||
"Device": "Device",
|
||||
"device name": "device name",
|
||||
"Device name": "Device name",
|
||||
"Device name to identify the device. Please use shorter one for the stable peer detection, i.e., \"iphone-16\" or \"macbook-2021\".": "Device name to identify the device. Please use shorter one for the stable peer detection, i.e., \"iphone-16\" or \"macbook-2021\".",
|
||||
"Device Peer ID": "Device Peer ID",
|
||||
"Device Setup Method": "Device Setup Method",
|
||||
"Devices:": "Devices:",
|
||||
"Diagnostic RTCPeerConnection is enabled": "Diagnostic RTCPeerConnection is enabled",
|
||||
"dialog.yourLanguageAvailable": "Self-hosted LiveSync had translations for your language, so the %{Display language} setting was enabled.\n\nNote: Not all messages are translated. We are waiting for your contributions!\nNote 2: If you create an Issue, **please revert to %{lang-def}** and then take screenshots, messages and logs. This can be done in the setting dialogue.\nMay you find it easy to use!",
|
||||
"dialog.yourLanguageAvailable.btnRevertToDefault": "Keep %{lang-def}",
|
||||
"dialog.yourLanguageAvailable.Title": " Translation is available!",
|
||||
"Diff": "Diff",
|
||||
"Different": "Different",
|
||||
"Disables all synchronization and restart.": "Disables all synchronization and restart.",
|
||||
"Disables logging, only shows notifications. Please disable if you report an issue.": "Disables logging, only shows notifications. Please disable if you report an issue.",
|
||||
"Disconnect": "Disconnect",
|
||||
"Dismiss": "Dismiss",
|
||||
"Display Language": "Display Language",
|
||||
"Display name": "Display name",
|
||||
"Do not check configuration mismatch before replication": "Do not check configuration mismatch before replication",
|
||||
@@ -136,33 +223,70 @@
|
||||
"Enable customization sync": "Enable customization sync",
|
||||
"Enable Developers' Debug Tools.": "Enable Developers' Debug Tools.",
|
||||
"Enable edge case treatment features": "Enable edge case treatment features",
|
||||
"Enable P2P Replicator": "Enable P2P Replicator",
|
||||
"Enable poweruser features": "Enable poweruser features",
|
||||
"Enable this if your Object Storage doesn't support CORS": "Enable this if your Object Storage doesn't support CORS",
|
||||
"Enable this option to automatically apply the most recent change to documents even when it conflicts": "Enable this option to automatically apply the most recent change to documents even when it conflicts",
|
||||
"Enabled": "Enabled",
|
||||
"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.": "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.",
|
||||
"Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.": "Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.",
|
||||
"Encrypting sensitive configuration items": "Encrypting sensitive configuration items",
|
||||
"Encryption Algorithm": "Encryption Algorithm",
|
||||
"Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.": "Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.",
|
||||
"End-to-End Encryption": "End-to-End Encryption",
|
||||
"Endpoint URL": "Endpoint URL",
|
||||
"Enhance chunk size": "Enhance chunk size",
|
||||
"Enter a folder prefix (optional)": "Enter a folder prefix (optional)",
|
||||
"Enter Server Information": "Enter Server Information",
|
||||
"Enter Setup URI": "Enter Setup URI",
|
||||
"Enter the server information manually": "Enter the server information manually",
|
||||
"Enter TURN credential": "Enter TURN credential",
|
||||
"Enter TURN username": "Enter TURN username",
|
||||
"Enter your Access Key ID": "Enter your Access Key ID",
|
||||
"Enter your Bucket Name": "Enter your Bucket Name",
|
||||
"Enter your database name": "Enter your database name",
|
||||
"Enter your JWT Key ID": "Enter your JWT Key ID",
|
||||
"Enter your JWT secret or private key": "Enter your JWT secret or private key",
|
||||
"Enter your JWT Subject (CouchDB Username)": "Enter your JWT Subject (CouchDB Username)",
|
||||
"Enter your passphrase": "Enter your passphrase",
|
||||
"Enter your password": "Enter your password",
|
||||
"Enter your Region (e.g., us-east-1, auto for R2)": "Enter your Region (e.g., us-east-1, auto for R2)",
|
||||
"Enter your Secret Access Key": "Enter your Secret Access Key",
|
||||
"Enter your username": "Enter your username",
|
||||
"Error during connection test: ${reason}": "Error during connection test: ${reason}",
|
||||
"Error during testAndFixSettings: ${reason}": "Error during testAndFixSettings: ${reason}",
|
||||
"Experimental Settings": "Experimental Settings",
|
||||
"Export": "Export",
|
||||
"Failed to connect to remote for compaction.": "Failed to connect to remote for compaction.",
|
||||
"Failed to connect to remote for compaction. ${reason}": "Failed to connect to remote for compaction. ${reason}",
|
||||
"Failed to connect to the server: ${reason}": "Failed to connect to the server: ${reason}",
|
||||
"Failed to connect to the server. Please check your settings.": "Failed to connect to the server. Please check your settings.",
|
||||
"Failed to connect to the signalling relay: ${reason}": "Failed to connect to the signalling relay: ${reason}",
|
||||
"Failed to create replicator instance.": "Failed to create replicator instance.",
|
||||
"Failed to parse Setup-URI.": "Failed to parse Setup-URI.",
|
||||
"Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": "Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.",
|
||||
"Failed to start replication after Garbage Collection.": "Failed to start replication after Garbage Collection.",
|
||||
"Failed:": "Failed:",
|
||||
"Fetch": "Fetch",
|
||||
"Fetch chunks on demand": "Fetch chunks on demand",
|
||||
"Fetch database with previous behaviour": "Fetch database with previous behaviour",
|
||||
"Fetch remote settings": "Fetch remote settings",
|
||||
"FETCHING": "FETCHING",
|
||||
"Fetching status...": "Fetching status...",
|
||||
"File integrity": "File integrity",
|
||||
"File to resolve conflict": "File to resolve conflict",
|
||||
"File to view History": "File to view History",
|
||||
"Filename": "Filename",
|
||||
"Final Confirmation: Overwrite Server Data with This Device's Files": "Final Confirmation: Overwrite Server Data with This Device's Files",
|
||||
"First, please select the option that best describes your current situation.": "First, please select the option that best describes your current situation.",
|
||||
"Fix": "Fix",
|
||||
"Flag and restart": "Flag and restart",
|
||||
"Flagged Selective": "Flagged Selective",
|
||||
"Folder Prefix": "Folder Prefix",
|
||||
"For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.": "For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.",
|
||||
"Forces the file to be synced when opened.": "Forces the file to be synced when opened.",
|
||||
"Fresh Start Wipe": "Fresh Start Wipe",
|
||||
"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.": "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.",
|
||||
"Garbage Collection cancelled by user.": "Garbage Collection cancelled by user.",
|
||||
"Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": "Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.",
|
||||
"Garbage Collection Confirmation": "Garbage Collection Confirmation",
|
||||
@@ -170,15 +294,33 @@
|
||||
"Garbage Collection: Found ${unusedChunks} unused chunks to delete.": "Garbage Collection: Found ${unusedChunks} unused chunks to delete.",
|
||||
"Garbage Collection: Scanned ${scanned} / ~${docCount}": "Garbage Collection: Scanned ${scanned} / ~${docCount}",
|
||||
"Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": "Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}",
|
||||
"Gathering information...": "Gathering information...",
|
||||
"Generate Random ID": "Generate Random ID",
|
||||
"Group ID": "Group ID",
|
||||
"Handle files as Case-Sensitive": "Handle files as Case-Sensitive",
|
||||
"Have you created a backup before proceeding?": "Have you created a backup before proceeding?",
|
||||
"Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.": "Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.",
|
||||
"Hidden Files": "Hidden Files",
|
||||
"Hide completely": "Hide completely",
|
||||
"Hide not applicable items": "Hide not applicable items",
|
||||
"Higher (${local} > ${remote})": "Higher (${local} > ${remote})",
|
||||
"Highlight diff": "Highlight diff",
|
||||
"How to display network errors when the sync server is unreachable.": "How to display network errors when the sync server is unreachable.",
|
||||
"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 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 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",
|
||||
"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.",
|
||||
"If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.": "If enabled, chunks will be split into no more than 100 items. However, dedupe is slightly weaker.",
|
||||
@@ -191,16 +333,38 @@
|
||||
"If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.": "If this enabled, chunks will be split into semantically meaningful segments. Not all platforms support this feature.",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": "If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.",
|
||||
"If you understand the risks and still wish to proceed, select so.": "If you understand the risks and still wish to proceed, select so.",
|
||||
"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.": "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.",
|
||||
"If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.": "If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.",
|
||||
"Ignore": "Ignore",
|
||||
"Ignore and Proceed": "Ignore and Proceed",
|
||||
"Ignore files": "Ignore files",
|
||||
"Ignore patterns": "Ignore patterns",
|
||||
"Import connection": "Import connection",
|
||||
"IMPORTANT": "IMPORTANT",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"Incoming:": "Incoming:",
|
||||
"Incubate Chunks in Document": "Incubate Chunks in Document",
|
||||
"Initial Action": "Initial Action",
|
||||
"Initialise all journal history, On the next sync, every item will be received and sent.": "Initialise all journal history, On the next sync, every item will be received and sent.",
|
||||
"Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.",
|
||||
"Initialise journal sent history. On the next sync, every item except this device received will be sent again.": "Initialise journal sent history. On the next sync, every item except this device received will be sent again.",
|
||||
"Interval (sec)": "Interval (sec)",
|
||||
"INVERTED": "INVERTED",
|
||||
"Issue detection log:": "Issue detection log:",
|
||||
"It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.": "It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.",
|
||||
"Just for a minute, please!": "Just for a minute, please!",
|
||||
"JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.": "JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.",
|
||||
"JWT Algorithm": "JWT Algorithm",
|
||||
"JWT Expiration Duration (minutes)": "JWT Expiration Duration (minutes)",
|
||||
"JWT Key": "JWT Key",
|
||||
"JWT Key ID (kid)": "JWT Key ID (kid)",
|
||||
"JWT Subject (sub)": "JWT Subject (sub)",
|
||||
"K.exp": "Experimental",
|
||||
"K.long_p2p_sync": "%{title_p2p_sync}",
|
||||
"K.P2P": "%{Peer}-to-%{Peer}",
|
||||
@@ -222,6 +386,7 @@
|
||||
"lang-zh-tw": "繁體中文",
|
||||
"Later": "Later",
|
||||
"Limit: {datetime} ({timestamp})": "Limit: {datetime} ({timestamp})",
|
||||
"live": "live",
|
||||
"LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.": "LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.",
|
||||
"liveSyncReplicator.beforeLiveSync": "Before LiveSync, start OneShot once...",
|
||||
"liveSyncReplicator.cantReplicateLowerValue": "We can't replicate more lower value.",
|
||||
@@ -250,6 +415,7 @@
|
||||
"liveSyncSetting.valueShouldBeInRange": "The value should ${min} < value < ${max}",
|
||||
"liveSyncSettings.btnApply": "Apply",
|
||||
"Local Database Tweak": "Local Database Tweak",
|
||||
"Local only": "Local only",
|
||||
"Lock": "Lock",
|
||||
"Lock Server": "Lock Server",
|
||||
"Lock the remote server to prevent synchronization with other devices.": "Lock the remote server to prevent synchronization with other devices.",
|
||||
@@ -258,6 +424,9 @@
|
||||
"logPane.pause": "Pause",
|
||||
"logPane.title": "Self-hosted LiveSync Log",
|
||||
"logPane.wrap": "Wrap",
|
||||
"Lower (${local} < ${remote})": "Lower (${local} < ${remote})",
|
||||
"Maintenance Commands": "Maintenance Commands",
|
||||
"Maintenance mode": "Maintenance mode",
|
||||
"Maximum delay for batch database updating": "Maximum delay for batch database updating",
|
||||
"Maximum file size": "Maximum file size",
|
||||
"Maximum Incubating Chunk Size": "Maximum Incubating Chunk Size",
|
||||
@@ -270,6 +439,7 @@
|
||||
"Merge": "Merge",
|
||||
"Minimum delay for batch database updating": "Minimum delay for batch database updating",
|
||||
"Minimum interval for syncing": "Minimum interval for syncing",
|
||||
"Mixed": "Mixed",
|
||||
"moduleCheckRemoteSize.logCheckingStorageSizes": "Checking storage sizes",
|
||||
"moduleCheckRemoteSize.logCurrentStorageSize": "Remote storage size: ${measuredSize}",
|
||||
"moduleCheckRemoteSize.logExceededWarning": "Remote storage size: ${measuredSize} exceeded ${notifySize}",
|
||||
@@ -352,23 +522,39 @@
|
||||
"moduleMigration.titleWelcome": "Welcome to Self-hosted LiveSync",
|
||||
"moduleObsidianMenu.replicate": "Replicate",
|
||||
"More actions": "More actions",
|
||||
"Mostly Complete: Decision Required": "Mostly Complete: Decision Required",
|
||||
"Move remotely deleted files to the trash, instead of deleting.": "Move remotely deleted files to the trash, instead of deleting.",
|
||||
"My remote server is already set up. I want to join this device.": "My remote server is already set up. I want to join this device.",
|
||||
"Name": "Name",
|
||||
"Network warning style": "Network warning style",
|
||||
"NEW": "NEW",
|
||||
"New Remote": "New Remote",
|
||||
"Newer (${diff})": "Newer (${diff})",
|
||||
"No checks have been performed yet.": "No checks have been performed yet.",
|
||||
"No connected device information found. Cancelling Garbage Collection.": "No connected device information found. Cancelling Garbage Collection.",
|
||||
"No Connection": "No Connection",
|
||||
"No devices available. Waiting for other devices to connect...": "No devices available. Waiting for other devices to connect...",
|
||||
"No Items.": "No Items.",
|
||||
"No limit configured": "No limit configured",
|
||||
"NO PREVIEW": "NO PREVIEW",
|
||||
"No, please take me back": "No, please take me back",
|
||||
"Node ID": "Node ID",
|
||||
"Node Information Missing": "Node Information Missing",
|
||||
"Non-Synchronising files": "Non-Synchronising files",
|
||||
"Normal Files": "Normal Files",
|
||||
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "Not all messages have been translated. And, please revert to \"Default\" when reporting errors.",
|
||||
"Not configured": "Not configured",
|
||||
"Not now": "Not now",
|
||||
"Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.": "Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.",
|
||||
"Note that you can generate a new Setup URI by running the \"Copy settings as a new Setup URI\" command in the command palette.": "Note that you can generate a new Setup URI by running the \"Copy settings as a new Setup URI\" command in the command palette.",
|
||||
"Notify all setting files": "Notify all setting files",
|
||||
"Notify customized": "Notify customized",
|
||||
"Notify when other device has newly customized.": "Notify when other device has newly customized.",
|
||||
"Notify when the estimated remote storage size exceeds on start up": "Notify when the estimated remote storage size exceeds on start up",
|
||||
"Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.": "Number of batches to process at a time. Defaults to 40. Minimum is 2. This along with batch size controls how many docs are kept in memory at a time.",
|
||||
"Number of changes to sync at a time. Defaults to 50. Minimum is 2.": "Number of changes to sync at a time. Defaults to 50. Minimum is 2.",
|
||||
"Obfuscate Properties": "Obfuscate Properties",
|
||||
"Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data.": "Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data.",
|
||||
"Obsidian version": "Obsidian version",
|
||||
"obsidianLiveSyncSettingTab.btnApply": "Apply",
|
||||
"obsidianLiveSyncSettingTab.btnCheck": "Check",
|
||||
@@ -541,16 +727,29 @@
|
||||
"obsidianLiveSyncSettingTab.titleUpdateThinning": "Update Thinning",
|
||||
"obsidianLiveSyncSettingTab.warnCorsOriginUnmatched": "⚠ CORS Origin is unmatched ${from}->${to}",
|
||||
"obsidianLiveSyncSettingTab.warnNoAdmin": "⚠ You do not have administrator privileges.",
|
||||
"Of course, we can back up the data before proceeding.": "Of course, we can back up the data before proceeding.",
|
||||
"Off": "Off",
|
||||
"Ok": "Ok",
|
||||
"Old Algorithm": "Old Algorithm",
|
||||
"Older (${diff})": "Older (${diff})",
|
||||
"Older fallback (Slow, W/O WebAssembly)": "Older fallback (Slow, W/O WebAssembly)",
|
||||
"On": "On",
|
||||
"On the source device, from the command palette, run the 'Show settings as a QR code' command.": "On the source device, from the command palette, run the 'Show settings as a QR code' command.",
|
||||
"On the source device, open Obsidian.": "On the source device, open Obsidian.",
|
||||
"On this device, please keep this Vault open.": "On this device, please keep this Vault open.",
|
||||
"On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.": "On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.",
|
||||
"Open": "Open",
|
||||
"Open connection": "Open connection",
|
||||
"Open P2P Setup...": "Open P2P Setup...",
|
||||
"Open the dialog": "Open the dialog",
|
||||
"Other files": "Other files",
|
||||
"Overwrite": "Overwrite",
|
||||
"Overwrite patterns": "Overwrite patterns",
|
||||
"Overwrite remote": "Overwrite remote",
|
||||
"Overwrite remote with local DB and passphrase.": "Overwrite remote with local DB and passphrase.",
|
||||
"Overwrite Server Data with This Device's Files": "Overwrite Server Data with This Device's Files",
|
||||
"P2P Configuration": "P2P Configuration",
|
||||
"P2P Status": "P2P Status",
|
||||
"P2P.AskPassphraseForDecrypt": "The remote peer shared the configuration. Please input the passphrase to decrypt the configuration.",
|
||||
"P2P.AskPassphraseForShare": "The remote peer requested this device configuration. Please input the passphrase to share the configuration. You can ignore the request by cancelling this dialogue.",
|
||||
"P2P.DisabledButNeed": "%{title_p2p_sync} is disabled. Do you really want to enable it?",
|
||||
@@ -574,36 +773,59 @@
|
||||
"paneMaintenance.remoteLockedResolvedDevice": "paneMaintenance.remoteLockedResolvedDevice",
|
||||
"paneMaintenance.unlockDatabaseReady": "paneMaintenance.unlockDatabaseReady",
|
||||
"Passphrase": "Passphrase",
|
||||
"Passphrase is required.": "Passphrase is required.",
|
||||
"Passphrase of sensitive configuration items": "Passphrase of sensitive configuration items",
|
||||
"password": "password",
|
||||
"Password": "Password",
|
||||
"Paste a connection string": "Paste a connection string",
|
||||
"Paste the Setup URI generated from one of your active devices.": "Paste the Setup URI generated from one of your active devices.",
|
||||
"Path": "Path",
|
||||
"Path Obfuscation": "Path Obfuscation",
|
||||
"Patterns to match files for overwriting instead of merging": "Patterns to match files for overwriting instead of merging",
|
||||
"Patterns to match files for syncing": "Patterns to match files for syncing",
|
||||
"Peer ID:": "Peer ID:",
|
||||
"Peer to Peer Replicator": "Peer to Peer Replicator",
|
||||
"Peer-to-Peer only": "Peer-to-Peer only",
|
||||
"Peer-to-Peer Synchronisation": "Peer-to-Peer Synchronisation",
|
||||
"Peers": "Peers",
|
||||
"Per-file-saved customization sync": "Per-file-saved customization sync",
|
||||
"Perform": "Perform",
|
||||
"Perform cleanup": "Perform cleanup",
|
||||
"Perform Garbage Collection": "Perform Garbage Collection",
|
||||
"Perform Garbage Collection to remove unused chunks and reduce database size.": "Perform Garbage Collection to remove unused chunks and reduce database size.",
|
||||
"Periodic Sync interval": "Periodic Sync interval",
|
||||
"PERMANENT": "PERMANENT",
|
||||
"Pick a file to resolve conflict": "Pick a file to resolve conflict",
|
||||
"Pick a file to show history": "Pick a file to show history",
|
||||
"Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data.": "Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data.",
|
||||
"Please configure your end-to-end encryption settings.": "Please configure your end-to-end encryption settings.",
|
||||
"Please disable 'Read chunks online' in settings to use Garbage Collection.": "Please disable 'Read chunks online' in settings to use Garbage Collection.",
|
||||
"Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": "Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.",
|
||||
"Please enter the CouchDB server information below.": "Please enter the CouchDB server information below.",
|
||||
"Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.": "Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.",
|
||||
"Please enter the Peer-to-Peer Synchronisation information below.": "Please enter the Peer-to-Peer Synchronisation information below.",
|
||||
"Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.": "Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.",
|
||||
"Please follow the steps below to import settings from your existing device.": "Please follow the steps below to import settings from your existing device.",
|
||||
"PLEASE NOTE": "PLEASE NOTE",
|
||||
"Please select 'Cancel' explicitly to cancel this operation.": "Please select 'Cancel' explicitly to cancel this operation.",
|
||||
"Please select a method to import the settings from another device.": "Please select a method to import the settings from another device.",
|
||||
"Please select an active P2P remote configuration to change P2P sync targets.": "Please select an active P2P remote configuration to change P2P sync targets.",
|
||||
"Please select an option to proceed": "Please select an option to proceed",
|
||||
"Please select the button below to restart and proceed to the data fetching confirmation.": "Please select the button below to restart and proceed to the data fetching confirmation.",
|
||||
"Please select the button below to restart and proceed to the final confirmation.": "Please select the button below to restart and proceed to the final confirmation.",
|
||||
"Please select the type of server to which you are connecting.": "Please select the type of server to which you are connecting.",
|
||||
"Please select your situation.": "Please select your situation.",
|
||||
"Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": "Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.",
|
||||
"Please set this device name": "Please set this device name",
|
||||
"Please understand that this is intended behaviour.": "Please understand that this is intended behaviour.",
|
||||
"Plug-in version": "Plug-in version",
|
||||
"Plugins": "Plugins",
|
||||
"Prepare the 'report' to create an issue": "Prepare the 'report' to create an issue",
|
||||
"Presets": "Presets",
|
||||
"Prevent fetching configuration from server": "Prevent fetching configuration from server",
|
||||
"Proceed": "Proceed",
|
||||
"Proceed Garbage Collection": "Proceed Garbage Collection",
|
||||
"Proceed to the next step.": "Proceed to the next step.",
|
||||
"Proceed with Setup URI": "Proceed with Setup URI",
|
||||
"Proceeding with Garbage Collection, ignoring missing nodes.": "Proceeding with Garbage Collection, ignoring missing nodes.",
|
||||
"Proceeding with Garbage Collection.": "Proceeding with Garbage Collection.",
|
||||
@@ -629,15 +851,22 @@
|
||||
"RedFlag.FetchRemoteConfig.Title": "Fetch Remote Configuration",
|
||||
"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.": "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.",
|
||||
"Reducing the frequency with which on-disk changes are reflected into the DB": "Reducing the frequency with which on-disk changes are reflected into the DB",
|
||||
"Refresh": "Refresh",
|
||||
"Region": "Region",
|
||||
"Relay settings": "Relay settings",
|
||||
"Reload": "Reload",
|
||||
"Remediation": "Remediation",
|
||||
"Remediation Setting Changed": "Remediation Setting Changed",
|
||||
"Remote Database Tweak (In sunset)": "Remote Database Tweak (In sunset)",
|
||||
"Remote Databases": "Remote Databases",
|
||||
"Remote name": "Remote name",
|
||||
"Remote only": "Remote only",
|
||||
"Remote server type": "Remote server type",
|
||||
"Remote Type": "Remote Type",
|
||||
"Rename": "Rename",
|
||||
"Replicate now": "Replicate now",
|
||||
"Replicating": "Replicating",
|
||||
"Replicating...": "Replicating...",
|
||||
"Replicator.Dialogue.Locked.Action.Dismiss": "Cancel for reconfirmation",
|
||||
"Replicator.Dialogue.Locked.Action.Fetch": "Reset Synchronisation on This Device",
|
||||
"Replicator.Dialogue.Locked.Action.Unlock": "Unlock the remote database",
|
||||
@@ -660,6 +889,7 @@
|
||||
"Reset": "Reset",
|
||||
"Reset all": "Reset all",
|
||||
"Reset all journal counter": "Reset all journal counter",
|
||||
"Reset and Resume Synchronisation": "Reset and Resume Synchronisation",
|
||||
"Reset journal received history": "Reset journal received history",
|
||||
"Reset journal sent history": "Reset journal sent history",
|
||||
"Reset notification threshold and check the remote database usage": "Reset notification threshold and check the remote database usage",
|
||||
@@ -672,14 +902,26 @@
|
||||
"Resolve all conflicted files": "Resolve all conflicted files",
|
||||
"Resolve All conflicted files by the newer one": "Resolve All conflicted files by the newer one",
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.": "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.",
|
||||
"Restart and Fetch Data": "Restart and Fetch Data",
|
||||
"Restart and Initialise Server": "Restart and Initialise Server",
|
||||
"Restart Now": "Restart Now",
|
||||
"Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?": "Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?",
|
||||
"Restore or reconstruct local database from remote.": "Restore or reconstruct local database from remote.",
|
||||
"Rev": "Rev",
|
||||
"Revert changes": "Revert changes",
|
||||
"Revoke": "Revoke",
|
||||
"Room ID": "Room ID",
|
||||
"Room ID suffix:": "Room ID suffix:",
|
||||
"Run Doctor": "Run Doctor",
|
||||
"S3/MinIO/R2 Configuration": "S3/MinIO/R2 Configuration",
|
||||
"S3/MinIO/R2 Object Storage": "S3/MinIO/R2 Object Storage",
|
||||
"Same": "Same",
|
||||
"Same or local only": "Same or local only",
|
||||
"Save and Apply": "Save and Apply",
|
||||
"Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.": "Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.",
|
||||
"Saving will be performed forcefully after this number of seconds.": "Saving will be performed forcefully after this number of seconds.",
|
||||
"Scan a QR Code (Recommended for mobile)": "Scan a QR Code (Recommended for mobile)",
|
||||
"Scan changes": "Scan changes",
|
||||
"Scan changes on customization sync": "Scan changes on customization sync",
|
||||
"Scan customization automatically": "Scan customization automatically",
|
||||
"Scan customization before replicating.": "Scan customization before replicating.",
|
||||
@@ -688,17 +930,28 @@
|
||||
"Scan for Broken files": "Scan for Broken files",
|
||||
"Scan for hidden files before replication": "Scan for hidden files before replication",
|
||||
"Scan hidden files periodically": "Scan hidden files periodically",
|
||||
"Scan QR Code": "Scan QR Code",
|
||||
"Scan the QR code displayed on an active device using this device's camera.": "Scan the QR code displayed on an active device using this device's camera.",
|
||||
"Schedule and Restart": "Schedule and Restart",
|
||||
"Scram Switches": "Scram Switches",
|
||||
"Scram!": "Scram!",
|
||||
"Seconds, 0 to disable": "Seconds, 0 to disable",
|
||||
"Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": "Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.",
|
||||
"Secret Access Key": "Secret Access Key",
|
||||
"Secret Key": "Secret Key",
|
||||
"Select active P2P remote": "Select active P2P remote",
|
||||
"Select All Shiny": "Select All Shiny",
|
||||
"Select Flagged Shiny": "Select Flagged Shiny",
|
||||
"Select P2P remote...": "Select P2P remote...",
|
||||
"Select the database adapter to use.": "Select the database adapter to use.",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"Selective": "Selective",
|
||||
"Send": "Send",
|
||||
"Send chunks": "Send chunks",
|
||||
"SENDING": "SENDING",
|
||||
"Server URI": "Server URI",
|
||||
"SESSION": "SESSION",
|
||||
"Setting.GenerateKeyPair.Desc": "We have generated a key pair!\n\nNote: This key pair will never be shown again. Please save it in a safe place. If you have lost it, you need to generate a new key pair.\nNote 2: The public key is in spki format, and the Private key is in pkcs8 format. For the sake of convenience, newlines are converted to `\\n` in public key.\nNote 3: The public key should be configured in the remote database, and the private key should be configured in local devices.\n\n>[!FOR YOUR EYES ONLY]-\n> <div class=\"sls-keypair\">\n>\n> ### Public Key\n> ```\n${public_key}\n> ```\n>\n> ### Private Key\n> ```\n${private_key}\n> ```\n>\n> </div>\n\n>[!Both for copying]-\n>\n> <div class=\"sls-keypair\">\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n> </div>\n\n",
|
||||
"Setting.GenerateKeyPair.Title": "New key pair has been generated!",
|
||||
"Setting.TroubleShooting": "TroubleShooting",
|
||||
@@ -707,7 +960,10 @@
|
||||
"Setting.TroubleShooting.ScanBrokenFiles": "Scan for broken files",
|
||||
"Setting.TroubleShooting.ScanBrokenFiles.Desc": "Scans for files that are not stored correctly in the database.",
|
||||
"SettingTab.Message.AskRebuild": "Your changes require fetching from the remote database. Do you want to proceed?",
|
||||
"Setup Complete: Preparing to Fetch Synchronisation Data": "Setup Complete: Preparing to Fetch Synchronisation Data",
|
||||
"Setup Complete: Preparing to Initialise Server": "Setup Complete: Preparing to Initialise Server",
|
||||
"Setup URI dialog cancelled.": "Setup URI dialog cancelled.",
|
||||
"Setup-URI": "Setup-URI",
|
||||
"Setup.Apply.Buttons.ApplyAndFetch": "Apply and Fetch",
|
||||
"Setup.Apply.Buttons.ApplyAndMerge": "Apply and Merge",
|
||||
"Setup.Apply.Buttons.ApplyAndRebuild": "Apply and Rebuild",
|
||||
@@ -777,16 +1033,29 @@
|
||||
"Show status inside the editor": "Show status inside the editor",
|
||||
"Show status on the status bar": "Show status on the status bar",
|
||||
"Show verbose log. Please enable if you report an issue.": "Show verbose log. Please enable if you report an issue.",
|
||||
"Signaling Server Connection": "Signaling Server Connection",
|
||||
"Signalling Status": "Signalling Status",
|
||||
"Skip and close": "Skip and close",
|
||||
"Snippets": "Snippets",
|
||||
"Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": "Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.",
|
||||
"Start Broadcasting": "Start Broadcasting",
|
||||
"Start change-broadcasting on Connect": "Start change-broadcasting on Connect",
|
||||
"Start Sync & Close": "Start Sync & Close",
|
||||
"Starts synchronisation when a file is saved.": "Starts synchronisation when a file is saved.",
|
||||
"Stat": "Stat",
|
||||
"Stats": "Stats",
|
||||
"Stop ⚡": "Stop ⚡",
|
||||
"Stop Broadcasting": "Stop Broadcasting",
|
||||
"Stop reflecting database changes to storage files.": "Stop reflecting database changes to storage files.",
|
||||
"Stop watching for file changes.": "Stop watching for file changes.",
|
||||
"Storage -> Database": "Storage -> Database",
|
||||
"Strongly Recommended": "Strongly Recommended",
|
||||
"Suppress notification of hidden files change": "Suppress notification of hidden files change",
|
||||
"Suspend database reflecting": "Suspend database reflecting",
|
||||
"Suspend file watching": "Suspend file watching",
|
||||
"Switch to IDB": "Switch to IDB",
|
||||
"Switch to IndexedDB": "Switch to IndexedDB",
|
||||
"Sync": "Sync",
|
||||
"Sync after merging file": "Sync after merging file",
|
||||
"Sync automatically after merging files": "Sync automatically after merging files",
|
||||
"Sync Mode": "Sync Mode",
|
||||
@@ -794,25 +1063,56 @@
|
||||
"Sync on File Open": "Sync on File Open",
|
||||
"Sync on Save": "Sync on Save",
|
||||
"Sync on Startup": "Sync on Startup",
|
||||
"Sync once": "Sync once",
|
||||
"Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.": "Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.",
|
||||
"Synchronising files": "Synchronising files",
|
||||
"Syncing": "Syncing",
|
||||
"Syncing...": "Syncing...",
|
||||
"Target patterns": "Target patterns",
|
||||
"Test Settings and Continue": "Test Settings and Continue",
|
||||
"Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.": "Testing only - Resolve file conflicts by syncing newer copies of the file, this can overwrite modified files. Be Warned.",
|
||||
"The connection to the server has been configured successfully. As the next step,": "The connection to the server has been configured successfully. As the next step,",
|
||||
"The delay for consecutive on-demand fetches": "The delay for consecutive on-demand fetches",
|
||||
"The files in this Vault are almost identical to the server's.": "The files in this Vault are almost identical to the server's.",
|
||||
"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.": "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.",
|
||||
"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.": "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.",
|
||||
"The Hash algorithm for chunk IDs": "The Hash algorithm for chunk IDs",
|
||||
"The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.": "The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.",
|
||||
"the latest synchronisation data will be downloaded from the server to this device.": "the latest synchronisation data will be downloaded from the server to this device.",
|
||||
"the local database, that is to say the synchronisation information, must be reconstituted.": "the local database, that is to say the synchronisation information, must be reconstituted.",
|
||||
"The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": "The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will 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 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 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.",
|
||||
"the single, authoritative master copy": "the single, authoritative master copy",
|
||||
"the synchronisation data on the server will be built based on the current data on this device.": "the synchronisation data on the server will be built based on the current data on this device.",
|
||||
"Themes": "Themes",
|
||||
"There is a way to resolve this on other devices.": "There is a way to resolve this on other devices.",
|
||||
"There may be differences between the files in this Vault and the server.": "There may be differences between the files in this Vault and the server.",
|
||||
"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.": "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.",
|
||||
"This can isolate your connections between devices. Use the same Room ID for the same devices.": "This can isolate your connections between devices. Use the same Room ID for the same devices.",
|
||||
"This device": "This device",
|
||||
"This device name": "This device name",
|
||||
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.",
|
||||
"This is an advanced option for users who do not have a URI or who wish to configure detailed settings.": "This is an advanced option for users who do not have a URI or who wish to configure detailed settings.",
|
||||
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.": "This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.",
|
||||
"This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.": "This is the most suitable synchronisation method for the design. All functions are available. You must have set up a CouchDB instance.",
|
||||
"This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.": "This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.",
|
||||
"This password is used to encrypt the connection. Use something long enough.": "This password is used to encrypt the connection. Use something long enough.",
|
||||
"This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as": "This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as",
|
||||
"This setting must be the same even when connecting to multiple synchronisation destinations.": "This setting must be the same even when connecting to multiple synchronisation destinations.",
|
||||
"This Vault is empty, or contains only new files that are not on the server.": "This Vault is empty, or contains only new files that are not on the server.",
|
||||
"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",
|
||||
"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.",
|
||||
"TURN Server URLs (comma-separated)": "TURN Server URLs (comma-separated)",
|
||||
"TURN Username": "TURN Username",
|
||||
"TweakMismatchResolve.Action.DisableAutoAcceptCompatible": "Disable auto-accept",
|
||||
"TweakMismatchResolve.Action.Dismiss": "Dismiss",
|
||||
"TweakMismatchResolve.Action.EnableAutoAcceptCompatible": "Enable auto-accept",
|
||||
@@ -1105,21 +1405,36 @@
|
||||
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continue to P2P setup",
|
||||
"Ui.SetupWizard.SetupRemote.Title": "Choose a synchronisation remote",
|
||||
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "Unique name between all synchronized devices. To edit this setting, please disable customization sync once.",
|
||||
"Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing.": "Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing.",
|
||||
"Updating list...": "Updating list...",
|
||||
"URL": "URL",
|
||||
"Use a custom passphrase": "Use a custom passphrase",
|
||||
"Use a Setup URI (Recommended)": "Use a Setup URI (Recommended)",
|
||||
"Use Custom HTTP Handler": "Use Custom HTTP Handler",
|
||||
"Use Diagnostic RTCPeerConnection for statistics": "Use Diagnostic RTCPeerConnection for statistics",
|
||||
"Use dynamic iteration count": "Use dynamic iteration count",
|
||||
"Use internal API": "Use internal API",
|
||||
"Use Internal API": "Use Internal API",
|
||||
"Use JWT Authentication": "Use JWT Authentication",
|
||||
"Use Path-Style Access": "Use Path-Style Access",
|
||||
"Use Random Number": "Use Random Number",
|
||||
"Use Segmented-splitter": "Use Segmented-splitter",
|
||||
"Use splitting-limit-capped chunk splitter": "Use splitting-limit-capped chunk splitter",
|
||||
"Use the trash bin": "Use the trash bin",
|
||||
"Use timeouts instead of heartbeats": "Use timeouts instead of heartbeats",
|
||||
"Use vrtmrz's relay": "Use vrtmrz's relay",
|
||||
"username": "username",
|
||||
"Username": "Username",
|
||||
"Verbose Log": "Verbose Log",
|
||||
"Verify all": "Verify all",
|
||||
"Verify and repair all files": "Verify and repair all files",
|
||||
"Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.": "Warning! This will have a serious impact on performance. And the logs will not be synchronised under the default name. Please be careful with logs; they often contain your confidential information.",
|
||||
"WATCHING": "WATCHING",
|
||||
"We can not use \"/\" to the device name": "We can not use \"/\" to the device name",
|
||||
"We can use only Secure (HTTPS) connections on Obsidian Mobile.": "We can use only Secure (HTTPS) connections on Obsidian Mobile.",
|
||||
"We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.": "We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.",
|
||||
"We have to configure the device name": "We have to configure the device name",
|
||||
"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.": "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.",
|
||||
"We will now guide you through a few questions to simplify the synchronisation setup.": "We will now guide you through a few questions to simplify the synchronisation setup.",
|
||||
"We will now proceed with the server configuration.": "We will now proceed with the server configuration.",
|
||||
"Welcome to Self-hosted LiveSync": "Welcome to Self-hosted LiveSync",
|
||||
@@ -1130,5 +1445,8 @@
|
||||
"xxhash64 (Fastest)": "xxhash64 (Fastest)",
|
||||
"Yes, I want to add this device to my existing synchronisation": "Yes, I want to add this device to my existing synchronisation",
|
||||
"Yes, I want to set up a new synchronisation": "Yes, I want to set up a new synchronisation",
|
||||
"You are adding this device to an existing synchronisation setup.": "You are adding this device to an existing synchronisation setup."
|
||||
"You are adding this device to an existing synchronisation setup.": "You are adding this device to an existing synchronisation setup.",
|
||||
"You can configure in the Obsidian Plugin Settings.": "You can configure in the Obsidian Plugin Settings.",
|
||||
"You should create a new synchronisation destination and rebuild your data there.": "You should create a new synchronisation destination and rebuild your data there.",
|
||||
"You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.": "You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+471
-135
File diff suppressed because it is too large
Load Diff
@@ -263,7 +263,7 @@
|
||||
"moduleCheckRemoteSize.logExceededWarning": "远程存储大小:${measuredSize} 超过 ${notifySize}",
|
||||
"moduleCheckRemoteSize.logThresholdEnlarged": "阈值已扩大到 ${size}MB",
|
||||
"moduleCheckRemoteSize.msgConfirmRebuild": "这可能需要一些时间。您真的想现在重建所有内容吗?",
|
||||
"moduleCheckRemoteSize.msgDatabaseGrowing": "**您的数据库正在变大!** 但别担心,我们现在可以解决它。在远程存储空间用完之前还有时间。\n\n| 测量大小 | 配置大小 |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> 如果您已经使用了很多年,数据库中可能会积累未引用的 chunks——也就是垃圾。因此,我们建议重建所有内容。它可能会变得小得多。\n> \n> 如果您的库容量只是在增加,最好在整理文件后重建所有内容。即使您为了加速过程删除了文件,Self-hosted LiveSync 也不会删除实际数据。这大致[有文档记录](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md)。\n> \n> 如果您不介意增加,可以将通知限制增加 100MB。如果您在自己的服务器上运行,就是这种情况。但是,最好还是不时地重建所有内容。\n> \n\n> [!WARNING]\n> 如果您执行重建所有内容,请确保所有设备都已同步。尽管如此,插件会尽可能地合并\n",
|
||||
"moduleCheckRemoteSize.msgDatabaseGrowing": "**您的数据库正在变大!** 但别担心,我们现在可以解决它。在远程存储空间用完之前还有时间。\n\n| 测量大小 | 配置大小 |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> 如果您已经使用了很多年,数据库中可能会积累未引用的 chunks——也就是垃圾。因此,我们建议重建所有内容。它可能会变得小得多。\n>\n> 如果您的库容量只是在增加,最好在整理文件后重建所有内容。即使您为了加速过程删除了文件,Self-hosted LiveSync 也不会删除实际数据。这大致[有文档记录](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md)。\n>\n> 如果您不介意增加,可以将通知限制增加 100MB。如果您在自己的服务器上运行,就是这种情况。但是,最好还是不时地重建所有内容。\n>\n\n> [!WARNING]\n> 如果您执行重建所有内容,请确保所有设备都已同步。尽管如此,插件会尽可能地合并\n",
|
||||
"moduleCheckRemoteSize.msgSetDBCapacity": "我们可以设置一个最大数据库容量警告,**以便在远程存储空间耗尽前采取行动**。\n您想启用这个功能吗?\n\n> [!MORE]-\n> - 0: 不警告存储大小。\n> 如果您在远程存储(尤其是自托管)上有足够的空间,则推荐此选项。您可以手动检查存储大小并重建。\n> - 800: 如果远程存储大小超过 800MB 则发出警告。\n> 如果您使用的是 fly.io(1GB 限制) 或 IBM Cloudant,则推荐此选项。\n> - 2000: 如果远程存储大小超过 2GB 则发出警告。\n\n如果达到限制,系统会要求我们逐步增大限制\n",
|
||||
"moduleCheckRemoteSize.option2GB": "2GB (标准)",
|
||||
"moduleCheckRemoteSize.option800MB": "800MB (Cloudant, fly.io)",
|
||||
@@ -317,7 +317,7 @@
|
||||
"moduleMigration.msgFetchRemoteAgain": "您可能已经知道,Self-hosted LiveSync 更改了其默认行为和数据库结构。\n\n值得庆幸的是,在您的时间和努力下,远程数据库似乎已经迁移完成。恭喜!\n\n但是,我们还需要一点点操作。此设备的配置与远程数据库不兼容。我们需要再次从远程数据库获取。我们现在应该再次从远程获取吗?\n\n___注意:在更改配置并再次获取数据库之前,我们无法进行同步。___\n___注意2:chunks 是完全不可变的,我们只能获取元数据和差异",
|
||||
"moduleMigration.msgInitialSetup": "您的设备**尚未设置**。让我引导您完成设置过程。\n\n请记住,每个对话框内容都可以复制到剪贴板。如果以后需要参考,可以将其粘贴到 Obsidian 的笔记中。您也可以使用翻译工具将其翻译成您的语言。\n\n首先,您有**设置 URI** 吗?\n\n注意:如果您不知道这是什么,请参阅[文档](${URI_DOC})",
|
||||
"moduleMigration.msgRecommendSetupUri": "我们强烈建议您生成一个设置 URI 并使用它。\n如果您对此不了解,请参阅[文档](${URI_DOC})(再次抱歉,但这很重要)。\n\n您想如何手动设置?",
|
||||
"moduleMigration.msgSinceV02321": "自 v0.23.21 起,Self-hosted LiveSync 更改了默认行为和数据库结构。进行了以下更改:\n\n1. **文件名的区分大小写** \n现在处理文件名时不区分大小写。这对于大多数平台来说是一个有益的更改,除了 Linux 和 iOS,它们不能有效地管理文件名的大小写敏感性。\n(在这些平台上,对于名称相同但大小写不同的文件将显示警告)。\n\n2. **chunks 的版本处理** \nchunks 是不可变的,这使得它们的版本可以固定。此更改将提高文件保存的性能。\n\n___然而,要启用这些更改中的任何一个,都需要重建远程和本地数据库。这个过程需要几分钟,我们建议您在有充足时间时进行。___\n\n- 如果您希望保持以前的行为,可以使用 `${KEEP}` 跳过此过程。\n- 如果您没有足够的时间,请选择 `${DISMISS}`。稍后会再次提示您。\n- 如果您已在另一台设备上重建了数据库,请选择 `${DISMISS}` 并尝试再次同步。由于检测到差异,系统会再次提示您",
|
||||
"moduleMigration.msgSinceV02321": "自 v0.23.21 起,Self-hosted LiveSync 更改了默认行为和数据库结构。进行了以下更改:\n\n1. **文件名的区分大小写**\n现在处理文件名时不区分大小写。这对于大多数平台来说是一个有益的更改,除了 Linux 和 iOS,它们不能有效地管理文件名的大小写敏感性。\n(在这些平台上,对于名称相同但大小写不同的文件将显示警告)。\n\n2. **chunks 的版本处理**\nchunks 是不可变的,这使得它们的版本可以固定。此更改将提高文件保存的性能。\n\n___然而,要启用这些更改中的任何一个,都需要重建远程和本地数据库。这个过程需要几分钟,我们建议您在有充足时间时进行。___\n\n- 如果您希望保持以前的行为,可以使用 `${KEEP}` 跳过此过程。\n- 如果您没有足够的时间,请选择 `${DISMISS}`。稍后会再次提示您。\n- 如果您已在另一台设备上重建了数据库,请选择 `${DISMISS}` 并尝试再次同步。由于检测到差异,系统会再次提示您",
|
||||
"moduleMigration.optionAdjustRemote": "调整到远程设置",
|
||||
"moduleMigration.optionDecideLater": "稍后决定",
|
||||
"moduleMigration.optionEnableBoth": "启用两者",
|
||||
@@ -402,7 +402,7 @@
|
||||
"obsidianLiveSyncSettingTab.logCheckingConfigDone": "配置检查完成",
|
||||
"obsidianLiveSyncSettingTab.logCheckingConfigFailed": "配置检查失败",
|
||||
"obsidianLiveSyncSettingTab.logCheckingDbConfig": "正在检查数据库配置",
|
||||
"obsidianLiveSyncSettingTab.logCheckPassphraseFailed": "错误:无法使用远程服务器检查密码:\n${db} ",
|
||||
"obsidianLiveSyncSettingTab.logCheckPassphraseFailed": "错误:无法使用远程服务器检查密码:\n${db}",
|
||||
"obsidianLiveSyncSettingTab.logConfiguredDisabled": "已配置的同步模式:已禁用",
|
||||
"obsidianLiveSyncSettingTab.logConfiguredLiveSync": "已配置的同步模式:LiveSync",
|
||||
"obsidianLiveSyncSettingTab.logConfiguredPeriodic": "已配置的同步模式:定期同步",
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
", 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.":
|
||||
", 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."
|
||||
(Active): (Active)
|
||||
(BETA) Always overwrite with a newer file: (BETA) Always overwrite with a newer file
|
||||
(Beta) Use ignore files: (Beta) Use ignore files
|
||||
(Days passed, 0 to disable automatic-deletion): (Days passed, 0 to disable automatic-deletion)
|
||||
(e.g., after editing many files whilst offline): (e.g., after editing many files whilst offline)
|
||||
(e.g., immediately after restoring on another computer, or having recovered from a backup):
|
||||
(e.g., immediately after restoring on another computer, or having recovered
|
||||
from a backup)
|
||||
(e.g., setting up for the first time on a new smartphone, starting from a clean slate):
|
||||
(e.g., setting up for the first time on a new smartphone, starting from a
|
||||
clean slate)
|
||||
(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.:
|
||||
(ex. Read chunks online) If this option is enabled, LiveSync reads chunks
|
||||
online directly instead of replicating them locally. Increasing Custom chunk
|
||||
@@ -11,6 +22,7 @@
|
||||
this will be skipped. If the file becomes smaller again, a newer one will be
|
||||
used.
|
||||
(Mega chars): (Mega chars)
|
||||
(Missing): (Missing)
|
||||
(Not recommended) If set, credentials will be stored in the file.: (Not recommended) If set, credentials will be stored in the file.
|
||||
(Obsolete) Use an old adapter for compatibility: (Obsolete) Use an old adapter for compatibility
|
||||
(RegExp) Empty to sync all files. Set filter as a regular expression to limit synchronising files.:
|
||||
@@ -19,21 +31,76 @@
|
||||
(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.
|
||||
"↑: Overwrite Remote": "↑: Overwrite Remote"
|
||||
"↓: Overwrite Local": "↓: Overwrite Local"
|
||||
"⇅: Use newer": "⇅: Use newer"
|
||||
+1 week: +1 week
|
||||
⚠️ Important Notice: ⚠️ Important Notice
|
||||
⚠️ Please Confirm the Following: ⚠️ Please Confirm the Following
|
||||
✔ SELECT: ✔ SELECT
|
||||
✔ SYNC: ✔ SYNC
|
||||
✔ WATCH: ✔ WATCH
|
||||
📡 Off: 📡 Off
|
||||
📡 On: 📡 On
|
||||
🔴 Disconnected: 🔴 Disconnected
|
||||
🕵️ Diag: 🕵️ Diag
|
||||
🗑 Delete: 🗑 Delete
|
||||
🟢 Connected: 🟢 Connected
|
||||
${count} issue(s) detected!: ${count} issue(s) detected!
|
||||
Accept: Accept
|
||||
Accept in session: Accept in session
|
||||
ACCEPTED: ACCEPTED
|
||||
ACCEPTED (in session): ACCEPTED (in session)
|
||||
Access Key: Access Key
|
||||
Access Key ID: Access Key ID
|
||||
Action: Action
|
||||
Activate: Activate
|
||||
Active Remote Configuration: Active Remote Configuration
|
||||
Add default patterns: Add default patterns
|
||||
Add new connection: Add new connection
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.:
|
||||
AddOn Module (ConfigSync) has not been loaded. This is very unexpected
|
||||
situation. Please report this issue.
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.:
|
||||
AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected
|
||||
situation. Please report this issue.
|
||||
Advanced: Advanced
|
||||
Advanced Settings: Advanced Settings
|
||||
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.:
|
||||
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.
|
||||
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.:
|
||||
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.
|
||||
After that, synchronise to a brand new vault on each other device with the new remote one by one.:
|
||||
After that, synchronise to a brand new vault on each other device with the
|
||||
new remote one by one.
|
||||
All checks passed successfully!: All checks passed successfully!
|
||||
All the same or non-existent: All the same or non-existent
|
||||
Allow in session: Allow in session
|
||||
Allow permanently: Allow permanently
|
||||
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.:
|
||||
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.
|
||||
Always prompt merge conflicts: Always prompt merge conflicts
|
||||
Analyse: Analyse
|
||||
Analyse database usage: Analyse database usage
|
||||
Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.:
|
||||
Analyse database usage and generate a TSV report for diagnosis yourself. You
|
||||
can paste the generated report with any spreadsheet you like.
|
||||
Apply All Selected: Apply All Selected
|
||||
Apply Latest Change if Conflicting: Apply Latest Change if Conflicting
|
||||
Apply preset configuration: Apply preset configuration
|
||||
Apply the settings: Apply the settings
|
||||
Ask a passphrase at every launch: Ask a passphrase at every launch
|
||||
Auto Connect: Auto Connect
|
||||
Auto Start P2P Connection: Auto Start P2P Connection
|
||||
Automatic: Automatic
|
||||
Automatically Sync all files when opening Obsidian.: Automatically Sync all files when opening Obsidian.
|
||||
Available Peers: Available Peers
|
||||
Back: Back
|
||||
Back to non-configured: Back to non-configured
|
||||
Batch database update: Batch database update
|
||||
@@ -45,8 +112,14 @@ Before v0.17.16, we used an old adapter for the local database. Now the new adap
|
||||
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.
|
||||
Broadcasting?: Broadcasting?
|
||||
Bucket Name: Bucket Name
|
||||
by resetting the remote, you will be informed on other devices.: by resetting the remote, you will be informed on other devices.
|
||||
Cancel: Cancel
|
||||
Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data.:
|
||||
Changing the encryption algorithm will prevent access to any data previously
|
||||
encrypted with a different algorithm. Ensure that all your devices are
|
||||
configured to use the same algorithm to maintain access to your data.
|
||||
Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.:
|
||||
Changing this setting requires migrating existing data (a bit time may be
|
||||
taken) and restarting Obsidian. Please make sure to back up your data before
|
||||
@@ -56,9 +129,18 @@ Check and convert non-path-obfuscated files: Check and convert non-path-obfuscat
|
||||
Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.:
|
||||
Check for documents that have not been converted to path-obfuscated IDs and
|
||||
convert them if necessary.
|
||||
Checking connection... Please wait.: Checking connection... Please wait.
|
||||
Chunks: Chunks
|
||||
Close: Close
|
||||
Close & Disconnect: Close & Disconnect
|
||||
Close this dialog: Close this dialog
|
||||
"Closed:": "Closed:"
|
||||
cmdConfigSync:
|
||||
showCustomizationSync: Show Customization sync
|
||||
Comma separated `.gitignore, .dockerignore`: Comma separated `.gitignore, .dockerignore`
|
||||
Command: Command
|
||||
Communicating: Communicating
|
||||
Compare file: Compare file
|
||||
Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.:
|
||||
Compare the content of files between on local database and storage. If not
|
||||
matched, you will be asked which one you want to keep.
|
||||
@@ -69,36 +151,64 @@ Compatibility (Metadata): Compatibility (Metadata)
|
||||
Compatibility (Remote Database): Compatibility (Remote Database)
|
||||
Compatibility (Trouble addressed): Compatibility (Trouble addressed)
|
||||
Compute revisions for chunks: Compute revisions for chunks
|
||||
Configuration: Configuration
|
||||
Configuration Encryption: Configuration Encryption
|
||||
Configure: Configure
|
||||
Configure And Change Remote: Configure And Change Remote
|
||||
Configure E2EE: Configure E2EE
|
||||
Configure Remote: Configure Remote
|
||||
Connect: Connect
|
||||
"Connected to Signaling Server (as Peer ID: ${peerId})": "Connected to Signaling Server (as Peer ID: ${peerId})"
|
||||
"Connected:": "Connected:"
|
||||
Connection Settings: Connection Settings
|
||||
"Connection:": "Connection:"
|
||||
Continue anyway: Continue anyway
|
||||
Copy: Copy
|
||||
Copy Report to clipboard: Copy Report to clipboard
|
||||
CouchDB Configuration: CouchDB Configuration
|
||||
CouchDB Connection Tweak: CouchDB Connection Tweak
|
||||
Create P2P remote: Create P2P remote
|
||||
Cross-platform: Cross-platform
|
||||
"Current adapter: {adapter}": "Current adapter: {adapter}"
|
||||
Custom Headers: Custom Headers
|
||||
Customization Sync: Customization Sync
|
||||
Customization Sync (Beta3): Customization Sync (Beta3)
|
||||
Data Compression: Data Compression
|
||||
Data to Copy: Data to Copy
|
||||
Database -> Storage: Database -> Storage
|
||||
Database Adapter: Database Adapter
|
||||
Database Name: Database Name
|
||||
Database suffix: Database suffix
|
||||
Date: Date
|
||||
Default: Default
|
||||
Delay conflict resolution of inactive files: Delay conflict resolution of inactive files
|
||||
Delay merge conflict prompt for inactive files.: Delay merge conflict prompt for inactive files.
|
||||
Delete: Delete
|
||||
Delete all customization sync data: Delete all customization sync data
|
||||
Delete all data on the remote server.: Delete all data on the remote server.
|
||||
Delete All of: Delete All of
|
||||
Delete local database to reset or uninstall Self-hosted LiveSync: Delete local database to reset or uninstall Self-hosted LiveSync
|
||||
Delete old metadata of deleted files on start-up: Delete old metadata of deleted files on start-up
|
||||
Delete Remote Configuration: Delete Remote Configuration
|
||||
Delete remote configuration '{name}'?: Delete remote configuration '{name}'?
|
||||
Delete remote configuration '${name}'?: Delete remote configuration '${name}'?
|
||||
DENIED: DENIED
|
||||
DENIED (in session): DENIED (in session)
|
||||
Deny: Deny
|
||||
Deny in session: Deny in session
|
||||
Deny permanently: Deny permanently
|
||||
Deselect all: Deselect all
|
||||
desktop: desktop
|
||||
Detected Peers: Detected Peers
|
||||
Developer: Developer
|
||||
device name: device name
|
||||
Device name: Device name
|
||||
Device name to identify the device. Please use shorter one for the stable peer detection, i.e., "iphone-16" or "macbook-2021".:
|
||||
Device name to identify the device. Please use shorter one for the stable
|
||||
peer detection, i.e., "iphone-16" or "macbook-2021".
|
||||
Device Peer ID: Device Peer ID
|
||||
"Devices:": "Devices:"
|
||||
Diagnostic RTCPeerConnection is enabled: Diagnostic RTCPeerConnection is enabled
|
||||
dialog:
|
||||
yourLanguageAvailable:
|
||||
_value: >-
|
||||
@@ -116,10 +226,14 @@ dialog:
|
||||
May you find it easy to use!
|
||||
btnRevertToDefault: Keep %{lang-def}
|
||||
Title: " Translation is available!"
|
||||
Diff: Diff
|
||||
Different: Different
|
||||
Disables all synchronization and restart.: Disables all synchronization and restart.
|
||||
Disables logging, only shows notifications. Please disable if you report an issue.:
|
||||
Disables logging, only shows notifications. Please disable if you report an
|
||||
issue.
|
||||
Disconnect: Disconnect
|
||||
Dismiss: Dismiss
|
||||
Display Language: Display Language
|
||||
Display name: Display name
|
||||
Do not check configuration mismatch before replication: Do not check configuration mismatch before replication
|
||||
@@ -202,38 +316,115 @@ Enable advanced features: Enable advanced features
|
||||
Enable customization sync: Enable customization sync
|
||||
Enable Developers' Debug Tools.: Enable Developers' Debug Tools.
|
||||
Enable edge case treatment features: Enable edge case treatment features
|
||||
Enable P2P Replicator: Enable P2P Replicator
|
||||
Enable poweruser features: Enable poweruser features
|
||||
Enable this if your Object Storage doesn't support CORS: Enable this if your Object Storage doesn't support CORS
|
||||
Enable this option to automatically apply the most recent change to documents even when it conflicts:
|
||||
Enable this option to automatically apply the most recent change to documents
|
||||
even when it conflicts
|
||||
Enabled: Enabled
|
||||
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.:
|
||||
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.
|
||||
Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommended.:
|
||||
Encrypt contents on the remote database. If you use the plugin's
|
||||
synchronization feature, enabling this is recommended.
|
||||
Encrypting sensitive configuration items: Encrypting sensitive configuration items
|
||||
Encryption Algorithm: Encryption Algorithm
|
||||
Encryption phassphrase. If changed, you should overwrite the server's database with the new (encrypted) files.:
|
||||
Encryption phassphrase. If changed, you should overwrite the server's database
|
||||
with the new (encrypted) files.
|
||||
End-to-End Encryption: End-to-End Encryption
|
||||
Endpoint URL: Endpoint URL
|
||||
Enhance chunk size: Enhance chunk size
|
||||
Enter a folder prefix (optional): Enter a folder prefix (optional)
|
||||
Enter Setup URI: Enter Setup URI
|
||||
Enter TURN credential: Enter TURN credential
|
||||
Enter TURN username: Enter TURN username
|
||||
Enter your Access Key ID: Enter your Access Key ID
|
||||
Enter your Bucket Name: Enter your Bucket Name
|
||||
Enter your database name: Enter your database name
|
||||
Enter your JWT Key ID: Enter your JWT Key ID
|
||||
Enter your JWT secret or private key: Enter your JWT secret or private key
|
||||
Enter your JWT Subject (CouchDB Username): Enter your JWT Subject (CouchDB Username)
|
||||
Enter your passphrase: Enter your passphrase
|
||||
Enter your password: Enter your password
|
||||
Enter your Region (e.g., us-east-1, auto for R2): Enter your Region (e.g., us-east-1, auto for R2)
|
||||
Enter your Secret Access Key: Enter your Secret Access Key
|
||||
Enter your username: Enter your username
|
||||
"Error during connection test: ${reason}": "Error during connection test: ${reason}"
|
||||
"Error during testAndFixSettings: ${reason}": "Error during testAndFixSettings: ${reason}"
|
||||
Experimental Settings: Experimental Settings
|
||||
Export: Export
|
||||
"Failed to connect to the server: ${reason}": "Failed to connect to the server: ${reason}"
|
||||
Failed to connect to the server. Please check your settings.: Failed to connect to the server. Please check your settings.
|
||||
"Failed to connect to the signalling relay: ${reason}": "Failed to connect to the signalling relay: ${reason}"
|
||||
Failed to create replicator instance.: Failed to create replicator instance.
|
||||
Failed to parse Setup-URI.: Failed to parse Setup-URI.
|
||||
"Failed:": "Failed:"
|
||||
Fetch: Fetch
|
||||
Fetch chunks on demand: Fetch chunks on demand
|
||||
Fetch database with previous behaviour: Fetch database with previous behaviour
|
||||
Fetch remote settings: Fetch remote settings
|
||||
FETCHING: FETCHING
|
||||
Fetching status...: Fetching status...
|
||||
File integrity: File integrity
|
||||
File to resolve conflict: File to resolve conflict
|
||||
File to view History: File to view History
|
||||
Filename: Filename
|
||||
"Final Confirmation: Overwrite Server Data with This Device's Files": "Final Confirmation: Overwrite Server Data with This Device's Files"
|
||||
Fix: Fix
|
||||
Flag and restart: Flag and restart
|
||||
Flagged Selective: Flagged Selective
|
||||
Folder Prefix: Folder Prefix
|
||||
For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key.:
|
||||
For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512
|
||||
algorithms, provide the pkcs8 PEM-formatted private key.
|
||||
Forces the file to be synced when opened.: Forces the file to be synced when opened.
|
||||
Fresh Start Wipe: Fresh Start Wipe
|
||||
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.:
|
||||
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.
|
||||
Garbage Collection V3 (Beta): Garbage Collection V3 (Beta)
|
||||
Gathering information...: Gathering information...
|
||||
Generate Random ID: Generate Random ID
|
||||
Group ID: Group ID
|
||||
Handle files as Case-Sensitive: Handle files as Case-Sensitive
|
||||
Have you created a backup before proceeding?: Have you created a backup before proceeding?
|
||||
Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.:
|
||||
Hidden file synchronization have been temporarily disabled. Please enable
|
||||
them after the fetching, if you need them.
|
||||
Hidden Files: Hidden Files
|
||||
Hide completely: Hide completely
|
||||
Hide not applicable items: Hide not applicable items
|
||||
Higher (${local} > ${remote}): Higher (${local} > ${remote})
|
||||
Highlight diff: Highlight diff
|
||||
How to display network errors when the sync server is unreachable.: How to display network errors when the sync server is unreachable.
|
||||
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 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 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
|
||||
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).
|
||||
@@ -267,13 +458,47 @@ If this option is enabled, PouchDB will hold the connection open for 60 seconds,
|
||||
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.
|
||||
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.:
|
||||
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.
|
||||
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.:
|
||||
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.
|
||||
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.:
|
||||
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.
|
||||
If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.:
|
||||
If you reached the payload size limit when using IBM Cloudant, please decrease
|
||||
batch size and batch limit to a lower value.
|
||||
If you understand the risks and still wish to proceed, select so.: If you understand the risks and still wish to proceed, select so.
|
||||
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.:
|
||||
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.
|
||||
If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.:
|
||||
If you want to use `LiveSync`, you should broadcast changes. All `watching`
|
||||
peers which detects this will start the replication for fetching.
|
||||
Ignore: Ignore
|
||||
Ignore files: Ignore files
|
||||
Ignore patterns: Ignore patterns
|
||||
Import connection: Import connection
|
||||
IMPORTANT: IMPORTANT
|
||||
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.:
|
||||
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.
|
||||
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.:
|
||||
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.
|
||||
"Incoming:": "Incoming:"
|
||||
Incubate Chunks in Document: Incubate Chunks in Document
|
||||
Initial Action: Initial Action
|
||||
Initialise all journal history, On the next sync, every item will be received and sent.:
|
||||
Initialise all journal history, On the next sync, every item will be received
|
||||
and sent.
|
||||
@@ -284,6 +509,23 @@ Initialise journal sent history. On the next sync, every item except this device
|
||||
Initialise journal sent history. On the next sync, every item except this
|
||||
device received will be sent again.
|
||||
Interval (sec): Interval (sec)
|
||||
INVERTED: INVERTED
|
||||
"Issue detection log:": "Issue detection log:"
|
||||
It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.:
|
||||
It is strongly advised to create a backup before proceeding. Continuing
|
||||
without a backup may lead to data loss.
|
||||
Just for a minute, please!: Just for a minute, please!
|
||||
JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.:
|
||||
JWT (JSON Web Token) authentication allows you to securely authenticate with
|
||||
the CouchDB server using tokens. Ensure that your CouchDB server is
|
||||
configured to accept JWTs and that the provided key and settings match the
|
||||
server's configuration. Incidentally, I have not verified it very
|
||||
thoroughly.
|
||||
JWT Algorithm: JWT Algorithm
|
||||
JWT Expiration Duration (minutes): JWT Expiration Duration (minutes)
|
||||
JWT Key: JWT Key
|
||||
JWT Key ID (kid): JWT Key ID (kid)
|
||||
JWT Subject (sub): JWT Subject (sub)
|
||||
K:
|
||||
exp: Experimental
|
||||
long_p2p_sync: "%{title_p2p_sync}"
|
||||
@@ -306,6 +548,7 @@ lang-zh: 简体中文
|
||||
lang-zh-tw: 繁體中文
|
||||
Later: Later
|
||||
"Limit: {datetime} ({timestamp})": "Limit: {datetime} ({timestamp})"
|
||||
live: live
|
||||
LiveSync could not handle multiple vaults which have same name without different prefix, This should be automatically configured.:
|
||||
LiveSync could not handle multiple vaults which have same name without
|
||||
different prefix, This should be automatically configured.
|
||||
@@ -342,6 +585,7 @@ liveSyncSetting:
|
||||
liveSyncSettings:
|
||||
btnApply: Apply
|
||||
Local Database Tweak: Local Database Tweak
|
||||
Local only: Local only
|
||||
Lock: Lock
|
||||
Lock Server: Lock Server
|
||||
Lock the remote server to prevent synchronization with other devices.: Lock the remote server to prevent synchronization with other devices.
|
||||
@@ -351,6 +595,9 @@ logPane:
|
||||
pause: Pause
|
||||
title: Self-hosted LiveSync Log
|
||||
wrap: Wrap
|
||||
Lower (${local} < ${remote}): Lower (${local} < ${remote})
|
||||
Maintenance Commands: Maintenance Commands
|
||||
Maintenance mode: Maintenance mode
|
||||
Maximum delay for batch database updating: Maximum delay for batch database updating
|
||||
Maximum file size: Maximum file size
|
||||
Maximum Incubating Chunk Size: Maximum Incubating Chunk Size
|
||||
@@ -363,6 +610,7 @@ Memory cache size (by total items): Memory cache size (by total items)
|
||||
Merge: Merge
|
||||
Minimum delay for batch database updating: Minimum delay for batch database updating
|
||||
Minimum interval for syncing: Minimum interval for syncing
|
||||
Mixed: Mixed
|
||||
moduleCheckRemoteSize:
|
||||
logCheckingStorageSizes: Checking storage sizes
|
||||
logCurrentStorageSize: "Remote storage size: ${measuredSize}"
|
||||
@@ -641,15 +889,33 @@ moduleMigration:
|
||||
moduleObsidianMenu:
|
||||
replicate: Replicate
|
||||
More actions: More actions
|
||||
"Mostly Complete: Decision Required": "Mostly Complete: Decision Required"
|
||||
Move remotely deleted files to the trash, instead of deleting.: Move remotely deleted files to the trash, instead of deleting.
|
||||
My remote server is already set up. I want to join this device.: My remote server is already set up. I want to join this device.
|
||||
Name: Name
|
||||
Network warning style: Network warning style
|
||||
NEW: NEW
|
||||
New Remote: New Remote
|
||||
Newer (${diff}): Newer (${diff})
|
||||
No checks have been performed yet.: No checks have been performed yet.
|
||||
No Connection: No Connection
|
||||
No devices available. Waiting for other devices to connect...: No devices available. Waiting for other devices to connect...
|
||||
No Items.: No Items.
|
||||
No limit configured: No limit configured
|
||||
NO PREVIEW: NO PREVIEW
|
||||
Non-Synchronising files: Non-Synchronising files
|
||||
Normal Files: Normal Files
|
||||
Not all messages have been translated. And, please revert to "Default" when reporting errors.:
|
||||
Not all messages have been translated. And, please revert to "Default" when
|
||||
reporting errors.
|
||||
Not configured: Not configured
|
||||
Not now: Not now
|
||||
Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.:
|
||||
Note that the Group ID is not limited to the generated format; you can use
|
||||
any string as the Group ID.
|
||||
Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.:
|
||||
Note that you can generate a new Setup URI by running the "Copy settings as
|
||||
a new Setup URI" command in the command palette.
|
||||
Notify all setting files: Notify all setting files
|
||||
Notify customized: Notify customized
|
||||
Notify when other device has newly customized.: Notify when other device has newly customized.
|
||||
@@ -658,6 +924,13 @@ Number of batches to process at a time. Defaults to 40. Minimum is 2. This along
|
||||
Number of batches to process at a time. Defaults to 40. Minimum is 2. This
|
||||
along with batch size controls how many docs are kept in memory at a time.
|
||||
Number of changes to sync at a time. Defaults to 50. Minimum is 2.: Number of changes to sync at a time. Defaults to 50. Minimum is 2.
|
||||
Obfuscate Properties: Obfuscate Properties
|
||||
Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data.:
|
||||
Obfuscating properties (e.g., path of file, size, creation and modification
|
||||
dates) adds an additional layer of security by making it harder to identify
|
||||
the structure and names of your files and folders on the remote server. This
|
||||
helps protect your privacy and makes it more difficult for unauthorized
|
||||
users to infer information about your data.
|
||||
obsidianLiveSyncSettingTab:
|
||||
btnApply: Apply
|
||||
btnCheck: Check
|
||||
@@ -912,11 +1185,26 @@ obsidianLiveSyncSettingTab:
|
||||
titleUpdateThinning: Update Thinning
|
||||
warnCorsOriginUnmatched: ⚠ CORS Origin is unmatched ${from}->${to}
|
||||
warnNoAdmin: ⚠ You do not have administrator privileges.
|
||||
Of course, we can back up the data before proceeding.: Of course, we can back up the data before proceeding.
|
||||
Off: Off
|
||||
Ok: Ok
|
||||
Old Algorithm: Old Algorithm
|
||||
Older (${diff}): Older (${diff})
|
||||
Older fallback (Slow, W/O WebAssembly): Older fallback (Slow, W/O WebAssembly)
|
||||
On: On
|
||||
On the source device, from the command palette, run the 'Show settings as a QR code' command.:
|
||||
On the source device, from the command palette, run the 'Show settings as a
|
||||
QR code' command.
|
||||
On the source device, open Obsidian.: On the source device, open Obsidian.
|
||||
On this device, please keep this Vault open.: On this device, please keep this Vault open.
|
||||
On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.:
|
||||
On this device, switch to the camera app or use a QR code scanner to scan
|
||||
the displayed QR code.
|
||||
Open: Open
|
||||
Open connection: Open connection
|
||||
Open P2P Setup...: Open P2P Setup...
|
||||
Open the dialog: Open the dialog
|
||||
Other files: Other files
|
||||
Overwrite: Overwrite
|
||||
Overwrite patterns: Overwrite patterns
|
||||
Overwrite remote: Overwrite remote
|
||||
@@ -968,34 +1256,70 @@ P2P:
|
||||
SyncAlreadyRunning: P2P Sync is already running.
|
||||
SyncCompleted: P2P Sync completed.
|
||||
SyncStartedWith: P2P Sync with ${name} have been started.
|
||||
P2P Configuration: P2P Configuration
|
||||
P2P Status: P2P Status
|
||||
paneMaintenance:
|
||||
markDeviceResolvedAfterBackup: paneMaintenance.markDeviceResolvedAfterBackup
|
||||
remoteLockedAndDeviceNotAccepted: paneMaintenance.remoteLockedAndDeviceNotAccepted
|
||||
remoteLockedResolvedDevice: paneMaintenance.remoteLockedResolvedDevice
|
||||
unlockDatabaseReady: paneMaintenance.unlockDatabaseReady
|
||||
Passphrase: Passphrase
|
||||
Passphrase is required.: Passphrase is required.
|
||||
Passphrase of sensitive configuration items: Passphrase of sensitive configuration items
|
||||
password: password
|
||||
Password: Password
|
||||
Paste a connection string: Paste a connection string
|
||||
Path: Path
|
||||
Path Obfuscation: Path Obfuscation
|
||||
Patterns to match files for overwriting instead of merging: Patterns to match files for overwriting instead of merging
|
||||
Patterns to match files for syncing: Patterns to match files for syncing
|
||||
"Peer ID:": "Peer ID:"
|
||||
Peer to Peer Replicator: Peer to Peer Replicator
|
||||
Peer-to-Peer Synchronisation: Peer-to-Peer Synchronisation
|
||||
Peers: Peers
|
||||
Per-file-saved customization sync: Per-file-saved customization sync
|
||||
Perform: Perform
|
||||
Perform cleanup: Perform cleanup
|
||||
Perform Garbage Collection: Perform Garbage Collection
|
||||
Perform Garbage Collection to remove unused chunks and reduce database size.: Perform Garbage Collection to remove unused chunks and reduce database size.
|
||||
Periodic Sync interval: Periodic Sync interval
|
||||
PERMANENT: PERMANENT
|
||||
Pick a file to resolve conflict: Pick a file to resolve conflict
|
||||
Pick a file to show history: Pick a file to show history
|
||||
Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data.:
|
||||
Please be aware that the End-to-End Encryption passphrase is not validated
|
||||
until the synchronisation process actually commences. This is a security
|
||||
measure designed to protect your data.
|
||||
Please configure your end-to-end encryption settings.: Please configure your end-to-end encryption settings.
|
||||
Please enter the CouchDB server information below.: Please enter the CouchDB server information below.
|
||||
Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.:
|
||||
Please enter the details required to connect to your S3/MinIO/R2 compatible
|
||||
object storage service.
|
||||
Please enter the Peer-to-Peer Synchronisation information below.: Please enter the Peer-to-Peer Synchronisation information below.
|
||||
Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.:
|
||||
Please enter the Setup URI that was generated during server installation or
|
||||
on another device, along with the vault passphrase.
|
||||
Please follow the steps below to import settings from your existing device.: Please follow the steps below to import settings from your existing device.
|
||||
PLEASE NOTE: PLEASE NOTE
|
||||
Please select an active P2P remote configuration to change P2P sync targets.: Please select an active P2P remote configuration to change P2P sync targets.
|
||||
Please select the button below to restart and proceed to the data fetching confirmation.:
|
||||
Please select the button below to restart and proceed to the data fetching
|
||||
confirmation.
|
||||
Please select the button below to restart and proceed to the final confirmation.:
|
||||
Please select the button below to restart and proceed to the final
|
||||
confirmation.
|
||||
Please select your situation.: Please select your situation.
|
||||
Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.:
|
||||
Please set device name to identify this device. This name should be unique
|
||||
among your devices. While not configured, we cannot enable this feature.
|
||||
Please set this device name: Please set this device name
|
||||
Please understand that this is intended behaviour.: Please understand that this is intended behaviour.
|
||||
Plugins: Plugins
|
||||
Prepare the 'report' to create an issue: Prepare the 'report' to create an issue
|
||||
Presets: Presets
|
||||
Prevent fetching configuration from server: Prevent fetching configuration from server
|
||||
Proceed: Proceed
|
||||
Proceed to the next step.: Proceed to the next step.
|
||||
Process small files in the foreground: Process small files in the foreground
|
||||
Property Encryption: Property Encryption
|
||||
PureJS fallback (Fast, W/O WebAssembly): PureJS fallback (Fast, W/O WebAssembly)
|
||||
@@ -1096,15 +1420,22 @@ Reduces storage space by discarding all non-latest revisions. This requires the
|
||||
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.
|
||||
Reducing the frequency with which on-disk changes are reflected into the DB: Reducing the frequency with which on-disk changes are reflected into the DB
|
||||
Refresh: Refresh
|
||||
Region: Region
|
||||
Relay settings: Relay settings
|
||||
Reload: Reload
|
||||
Remediation: Remediation
|
||||
Remediation Setting Changed: Remediation Setting Changed
|
||||
Remote Database Tweak (In sunset): Remote Database Tweak (In sunset)
|
||||
Remote Databases: Remote Databases
|
||||
Remote name: Remote name
|
||||
Remote only: Remote only
|
||||
Remote server type: Remote server type
|
||||
Remote Type: Remote Type
|
||||
Rename: Rename
|
||||
Replicate now: Replicate now
|
||||
Replicating: Replicating
|
||||
Replicating...: Replicating...
|
||||
Replicator:
|
||||
Dialogue:
|
||||
Locked:
|
||||
@@ -1150,6 +1481,7 @@ Resend all chunks to the remote.: Resend all chunks to the remote.
|
||||
Reset: Reset
|
||||
Reset all: Reset all
|
||||
Reset all journal counter: Reset all journal counter
|
||||
Reset and Resume Synchronisation: Reset and Resume Synchronisation
|
||||
Reset journal received history: Reset journal received history
|
||||
Reset journal sent history: Reset journal sent history
|
||||
Reset notification threshold and check the remote database usage: Reset notification threshold and check the remote database usage
|
||||
@@ -1166,16 +1498,28 @@ Resolve All conflicted files by the newer one: Resolve All conflicted files by t
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one.":
|
||||
"Resolve all conflicted files by the newer one. Caution: This will overwrite
|
||||
the older one, and cannot resurrect the overwritten one."
|
||||
Restart and Fetch Data: Restart and Fetch Data
|
||||
Restart and Initialise Server: Restart and Initialise Server
|
||||
Restart Now: Restart Now
|
||||
Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?:
|
||||
Restarting Obsidian is strongly recommended. Until restart, some changes may
|
||||
not take effect, and display may be inconsistent. Are you sure to restart now?
|
||||
Restore or reconstruct local database from remote.: Restore or reconstruct local database from remote.
|
||||
Rev: Rev
|
||||
Revert changes: Revert changes
|
||||
Revoke: Revoke
|
||||
Room ID: Room ID
|
||||
"Room ID suffix:": "Room ID suffix:"
|
||||
Run Doctor: Run Doctor
|
||||
S3/MinIO/R2 Configuration: S3/MinIO/R2 Configuration
|
||||
Same: Same
|
||||
Same or local only: Same or local only
|
||||
Save and Apply: Save and Apply
|
||||
Save settings to a markdown file. You will be notified when new settings arrive. You can set different files by the platform.:
|
||||
Save settings to a markdown file. You will be notified when new settings
|
||||
arrive. You can set different files by the platform.
|
||||
Saving will be performed forcefully after this number of seconds.: Saving will be performed forcefully after this number of seconds.
|
||||
Scan changes: Scan changes
|
||||
Scan changes on customization sync: Scan changes on customization sync
|
||||
Scan customization automatically: Scan customization automatically
|
||||
Scan customization before replicating.: Scan customization before replicating.
|
||||
@@ -1184,6 +1528,7 @@ Scan customization periodically: Scan customization periodically
|
||||
Scan for Broken files: Scan for Broken files
|
||||
Scan for hidden files before replication: Scan for hidden files before replication
|
||||
Scan hidden files periodically: Scan hidden files periodically
|
||||
Scan QR Code: Scan QR Code
|
||||
Schedule and Restart: Schedule and Restart
|
||||
Scram Switches: Scram Switches
|
||||
Scram!: Scram!
|
||||
@@ -1191,11 +1536,27 @@ Seconds, 0 to disable: Seconds, 0 to disable
|
||||
Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.:
|
||||
Seconds. Saving to the local database will be delayed until this value after
|
||||
we stop typing or saving.
|
||||
Secret Access Key: Secret Access Key
|
||||
Secret Key: Secret Key
|
||||
Select active P2P remote: Select active P2P remote
|
||||
Select All Shiny: Select All Shiny
|
||||
Select Flagged Shiny: Select Flagged Shiny
|
||||
Select P2P remote...: Select P2P remote...
|
||||
Select the database adapter to use.: Select the database adapter to use.
|
||||
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.:
|
||||
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.
|
||||
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.:
|
||||
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.
|
||||
Selective: Selective
|
||||
Send: Send
|
||||
Send chunks: Send chunks
|
||||
SENDING: SENDING
|
||||
Server URI: Server URI
|
||||
SESSION: SESSION
|
||||
Setting:
|
||||
GenerateKeyPair:
|
||||
Desc: >+
|
||||
@@ -1389,6 +1750,8 @@ Setup:
|
||||
ButtonClose: Close this dialog
|
||||
"Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.
|
||||
"Please disable 'Read chunks online' in settings to use Garbage Collection.": Please disable 'Read chunks online' in settings to use Garbage Collection.
|
||||
"Setup Complete: Preparing to Fetch Synchronisation Data": "Setup Complete: Preparing to Fetch Synchronisation Data"
|
||||
"Setup Complete: Preparing to Initialise Server": "Setup Complete: Preparing to Initialise Server"
|
||||
"Setup URI dialog cancelled.": Setup URI dialog cancelled.
|
||||
"Please select 'Cancel' explicitly to cancel this operation.": Please select 'Cancel' explicitly to cancel this operation.
|
||||
"Failed to connect to remote for compaction.": Failed to connect to remote for compaction.
|
||||
@@ -1400,6 +1763,27 @@ Setup:
|
||||
"Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.
|
||||
"Cancel Garbage Collection": Cancel Garbage Collection
|
||||
"No connected device information found. Cancelling Garbage Collection.": No connected device information found. Cancelling Garbage Collection.
|
||||
Setup-URI: Setup-URI
|
||||
Signaling Server Connection: Signaling Server Connection
|
||||
Signalling Status: Signalling Status
|
||||
Skip and close: Skip and close
|
||||
Snippets: Snippets
|
||||
Start Broadcasting: Start Broadcasting
|
||||
Start change-broadcasting on Connect: Start change-broadcasting on Connect
|
||||
Start Sync & Close: Start Sync & Close
|
||||
Stat: Stat
|
||||
Stats: Stats
|
||||
Stop ⚡: Stop ⚡
|
||||
Stop Broadcasting: Stop Broadcasting
|
||||
Strongly Recommended: Strongly Recommended
|
||||
Sync: Sync
|
||||
Sync once: Sync once
|
||||
Syncing...: Syncing...
|
||||
Test Settings and Continue: Test Settings and Continue
|
||||
The connection to the server has been configured successfully. As the next step,:
|
||||
The connection to the server has been configured successfully. As the next
|
||||
step,
|
||||
The files in this Vault are almost identical to the server's.: The files in this Vault are almost identical to the server's.
|
||||
"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.":
|
||||
|-
|
||||
The following accepted nodes are missing its node information:
|
||||
@@ -1474,11 +1858,21 @@ Testing only - Resolve file conflicts by syncing newer copies of the file, this
|
||||
Testing only - Resolve file conflicts by syncing newer copies of the file,
|
||||
this can overwrite modified files. Be Warned.
|
||||
The delay for consecutive on-demand fetches: The delay for consecutive on-demand fetches
|
||||
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.:
|
||||
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.
|
||||
The Hash algorithm for chunk IDs: The Hash algorithm for chunk IDs
|
||||
The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.:
|
||||
The IndexedDB adapter often offers superior performance in certain scenarios,
|
||||
but it has been found to cause memory leaks when used with LiveSync mode. When
|
||||
using LiveSync mode, please use IDB adapter instead.
|
||||
the latest synchronisation data will be downloaded from the server to this device.:
|
||||
the latest synchronisation data will be downloaded from the server to this
|
||||
device.
|
||||
the local database, that is to say the synchronisation information, must be reconstituted.:
|
||||
the local database, that is to say the synchronisation information, must be
|
||||
reconstituted.
|
||||
The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.:
|
||||
The maximum duration for which chunks can be incubated within the document.
|
||||
Chunks exceeding this period will graduate to independent chunks.
|
||||
@@ -1489,13 +1883,61 @@ The maximum total size of chunks that can be incubated within the document. Chun
|
||||
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 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.
|
||||
the single, authoritative master copy: the single, authoritative master copy
|
||||
the synchronisation data on the server will be built based on the current data on this device.:
|
||||
the synchronisation data on the server will be built based on the current
|
||||
data on this device.
|
||||
Themes: Themes
|
||||
There is a way to resolve this on other devices.: There is a way to resolve this on other devices.
|
||||
There may be differences between the files in this Vault and the server.: There may be differences between the files in this Vault and the server.
|
||||
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.:
|
||||
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.
|
||||
This can isolate your connections between devices. Use the same Room ID for the same devices.:
|
||||
This can isolate your connections between devices. Use the same Room ID for
|
||||
the same devices.
|
||||
This device: This device
|
||||
This device name: This device name
|
||||
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location.:
|
||||
This is an extremely powerful operation. We strongly recommend that you copy
|
||||
your Vault folder to a safe location.
|
||||
This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.:
|
||||
This passphrase will not be copied to another device. It will be set to
|
||||
`Default` until you configure it again.
|
||||
This password is used to encrypt the connection. Use something long enough.: This password is used to encrypt the connection. Use something long enough.
|
||||
This procedure will first delete all existing synchronisation data from the server. Following this, the server data will be completely rebuilt, using the current state of your Vault on this device (including its local database) as:
|
||||
This procedure will first delete all existing synchronisation data from the
|
||||
server. Following this, the server data will be completely rebuilt, using
|
||||
the current state of your Vault on this device (including its local
|
||||
database) as
|
||||
This setting must be the same even when connecting to multiple synchronisation destinations.:
|
||||
This setting must be the same even when connecting to multiple
|
||||
synchronisation destinations.
|
||||
This Vault is empty, or contains only new files that are not on the server.: This Vault is empty, or contains only new files that are not on the server.
|
||||
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
|
||||
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.
|
||||
TURN Server URLs (comma-separated): TURN Server URLs (comma-separated)
|
||||
TURN Username: TURN Username
|
||||
TweakMismatchResolve:
|
||||
Action:
|
||||
Dismiss: Dismiss
|
||||
@@ -1608,13 +2050,26 @@ TweakMismatchResolve:
|
||||
Unique name between all synchronized devices. To edit this setting, please disable customization sync once.:
|
||||
Unique name between all synchronized devices. To edit this setting, please
|
||||
disable customization sync once.
|
||||
Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing.:
|
||||
Unless you are certain, selecting this options is bit dangerous. It assumes
|
||||
that the server configuration is compatible with this device. If this is not
|
||||
the case, data loss may occur. Please ensure you know what you are doing.
|
||||
Updating list...: Updating list...
|
||||
URL: URL
|
||||
Use a custom passphrase: Use a custom passphrase
|
||||
Use Custom HTTP Handler: Use Custom HTTP Handler
|
||||
Use Diagnostic RTCPeerConnection for statistics: Use Diagnostic RTCPeerConnection for statistics
|
||||
Use dynamic iteration count: Use dynamic iteration count
|
||||
Use internal API: Use internal API
|
||||
Use Internal API: Use Internal API
|
||||
Use JWT Authentication: Use JWT Authentication
|
||||
Use Path-Style Access: Use Path-Style Access
|
||||
Use Random Number: Use Random Number
|
||||
Use Segmented-splitter: Use Segmented-splitter
|
||||
Use splitting-limit-capped chunk splitter: Use splitting-limit-capped chunk splitter
|
||||
Use the trash bin: Use the trash bin
|
||||
Use timeouts instead of heartbeats: Use timeouts instead of heartbeats
|
||||
Use vrtmrz's relay: Use vrtmrz's relay
|
||||
username: username
|
||||
Username: Username
|
||||
Verbose Log: Verbose Log
|
||||
@@ -1624,9 +2079,17 @@ Warning! This will have a serious impact on performance. And the logs will not b
|
||||
Warning! This will have a serious impact on performance. And the logs will not
|
||||
be synchronised under the default name. Please be careful with logs; they
|
||||
often contain your confidential information.
|
||||
WATCHING: WATCHING
|
||||
We can not use "/" to the device name: We can not use "/" to the device name
|
||||
We can use only Secure (HTTPS) connections on Obsidian Mobile.: We can use only Secure (HTTPS) connections on Obsidian Mobile.
|
||||
We cannot change the device name while this feature is enabled. Please disable this feature to change the device name.:
|
||||
We cannot change the device name while this feature is enabled. Please disable
|
||||
this feature to change the device name.
|
||||
We have to configure the device name: We have to configure the device name
|
||||
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.:
|
||||
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.
|
||||
When you save a file in the editor, start a sync automatically: When you save a file in the editor, start a sync automatically
|
||||
Write credentials in the file: Write credentials in the file
|
||||
Write logs into the file: Write logs into the file
|
||||
@@ -1961,3 +2424,14 @@ Ui:
|
||||
ProceedCouchDb: Continue to CouchDB setup
|
||||
ProceedP2P: Continue to P2P setup
|
||||
Title: Choose a synchronisation remote
|
||||
|
||||
You can configure in the Obsidian Plugin Settings.: You can configure in the Obsidian Plugin Settings.
|
||||
|
||||
You should create a new synchronisation destination and rebuild your data there.:
|
||||
You should create a new synchronisation destination and rebuild your data
|
||||
there.
|
||||
You should perform this operation only in exceptional circumstances, such as when the server data is completely corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually large in comparison to the Vault size.:
|
||||
You should perform this operation only in exceptional circumstances, such as
|
||||
when the server data is completely corrupted, when changes on all other
|
||||
devices are no longer needed, or when the database size has become unusually
|
||||
large in comparison to the Vault size.
|
||||
|
||||
+1695
-68
File diff suppressed because it is too large
Load Diff
+742
-402
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type PluginManifest, TFile } from "@/deps.ts";
|
||||
import type { PluginManifest, TFile } from "@/deps.ts";
|
||||
import { type DatabaseEntry, type EntryBody, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
export type { CacheData, FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
|
||||
@@ -1362,7 +1362,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
|
||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||
if (term == "") {
|
||||
this._log("We have to configure the device name", LOG_LEVEL_NOTICE);
|
||||
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
if (this.useV2) {
|
||||
@@ -1552,7 +1552,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
this._log("Scanning customizing files.", logLevel, "scan-all-config");
|
||||
const term = this.services.setting.getDeviceAndVaultName();
|
||||
if (term == "") {
|
||||
this._log("We have to configure the device name", LOG_LEVEL_NOTICE);
|
||||
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const filesAll = await this.scanInternalFiles();
|
||||
@@ -1729,7 +1729,11 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
if (mode == "CUSTOMIZE") {
|
||||
if (!this.services.setting.getDeviceAndVaultName()) {
|
||||
let name = await this.core.confirm.askString("Device name", "Please set this device name", `desktop`);
|
||||
let name = await this.core.confirm.askString(
|
||||
$msg("Device name"),
|
||||
$msg("Please set this device name"),
|
||||
`desktop`
|
||||
);
|
||||
if (!name) {
|
||||
if (Platform.isAndroidApp) {
|
||||
name = "android-app";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
// import { askString } from "../../common/utils";
|
||||
import { Menu } from "@/deps.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
export let list: IPluginDataExDisplay[] = [];
|
||||
export let thisTerm = "";
|
||||
@@ -61,25 +62,25 @@
|
||||
// NO OP. what's happened?
|
||||
freshness = "";
|
||||
} else if (local && !remote) {
|
||||
freshness = "Local only";
|
||||
freshness = translateMessage("Local only");
|
||||
} else if (remote && !local) {
|
||||
freshness = "Remote only";
|
||||
freshness = translateMessage("Remote only");
|
||||
canApply = true;
|
||||
} else {
|
||||
const dtDiff = (local?.mtime ?? 0) - (remote?.mtime ?? 0);
|
||||
const diff = timeDeltaToHumanReadable(Math.abs(dtDiff));
|
||||
if (dtDiff / 1000 < -10) {
|
||||
// freshness = "✓ Newer";
|
||||
freshness = `Newer (${diff})`;
|
||||
freshness = translateMessage("Newer (${diff})", { diff });
|
||||
canApply = true;
|
||||
contentCheck = true;
|
||||
} else if (dtDiff / 1000 > 10) {
|
||||
// freshness = "⚠ Older";
|
||||
freshness = `Older (${diff})`;
|
||||
freshness = translateMessage("Older (${diff})", { diff });
|
||||
canApply = true;
|
||||
contentCheck = true;
|
||||
} else {
|
||||
freshness = "Same";
|
||||
freshness = translateMessage("Same");
|
||||
canApply = false;
|
||||
contentCheck = true;
|
||||
}
|
||||
@@ -89,11 +90,17 @@
|
||||
if (local?.version || remote?.version) {
|
||||
const compare = `${localVersionStr}`.localeCompare(remoteVersionStr, undefined, { numeric: true });
|
||||
if (compare == 0) {
|
||||
version = "Same";
|
||||
version = translateMessage("Same");
|
||||
} else if (compare < 0) {
|
||||
version = `Lower (${localVersionStr} < ${remoteVersionStr})`;
|
||||
version = translateMessage("Lower (${local} < ${remote})", {
|
||||
local: localVersionStr,
|
||||
remote: remoteVersionStr,
|
||||
});
|
||||
} else if (compare > 0) {
|
||||
version = `Higher (${localVersionStr} > ${remoteVersionStr})`;
|
||||
version = translateMessage("Higher (${local} > ${remote})", {
|
||||
local: localVersionStr,
|
||||
remote: remoteVersionStr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,19 +142,19 @@
|
||||
})
|
||||
.reduce((p, c) => p | (c as number), 0 as number);
|
||||
if (matchingStatus == 0b0000100) {
|
||||
equivalency = "Same";
|
||||
equivalency = translateMessage("Same");
|
||||
canApply = false;
|
||||
} else if (matchingStatus <= 0b0000100) {
|
||||
equivalency = "Same or local only";
|
||||
equivalency = translateMessage("Same or local only");
|
||||
canApply = false;
|
||||
} else if (matchingStatus == 0b0010000) {
|
||||
canApply = true;
|
||||
canCompare = true;
|
||||
equivalency = "Different";
|
||||
equivalency = translateMessage("Different");
|
||||
} else {
|
||||
canApply = true;
|
||||
canCompare = true;
|
||||
equivalency = "Mixed";
|
||||
equivalency = translateMessage("Mixed");
|
||||
}
|
||||
return { equivalency, canApply, canCompare };
|
||||
}
|
||||
@@ -244,7 +251,7 @@
|
||||
if (selected == "") {
|
||||
// NO OP.
|
||||
} else if (selected == thisTerm) {
|
||||
freshness = "This device";
|
||||
freshness = translateMessage("This device");
|
||||
canApply = false;
|
||||
} else {
|
||||
const local = list.find((e) => e.term == thisTerm);
|
||||
@@ -304,11 +311,11 @@
|
||||
if (!local) return;
|
||||
if (!selectedItem) return;
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle("Compare file").setIsLabel(true));
|
||||
menu.addItem((item) => item.setTitle(translateMessage("Compare file")).setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
const files = unique(local.files.map((e) => e.filename).concat(selectedItem.files.map((e) => e.filename)));
|
||||
const convDate = (dt: PluginDataExFile | undefined) => {
|
||||
if (!dt) return "(Missing)";
|
||||
if (!dt) return translateMessage("(Missing)");
|
||||
const d = new Date(dt.mtime);
|
||||
return d.toLocaleString();
|
||||
};
|
||||
@@ -335,10 +342,14 @@
|
||||
Logger(`Could not find local item`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const duplicateTermName = await core.confirm.askString("Duplicate", "device name", "");
|
||||
const duplicateTermName = await core.confirm.askString(
|
||||
translateMessage("Duplicate"),
|
||||
translateMessage("device name"),
|
||||
""
|
||||
);
|
||||
if (duplicateTermName) {
|
||||
if (duplicateTermName.contains("/")) {
|
||||
Logger(`We can not use "/" to the device name`, LOG_LEVEL_NOTICE);
|
||||
Logger(translateMessage('We can not use "/" to the device name'), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const key = `${plugin.core.services.API.getSystemConfigDir()}/${local.files[0].filename}`;
|
||||
@@ -391,7 +402,7 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
<span class="message even">All the same or non-existent</span>
|
||||
<span class="message even">{translateMessage("All the same or non-existent")}</span>
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button disabled></button>
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let core :LiveSyncBaseCore;
|
||||
// $: core = plugin.core;
|
||||
@@ -32,15 +33,17 @@
|
||||
|
||||
const addOn = core.getAddOn<ConfigSync>(ConfigSync.name)!;
|
||||
if (!addOn) {
|
||||
const msg =
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
const msg = translateMessage(
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue."
|
||||
);
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const addOnHiddenFileSync = core.getAddOn<HiddenFileSync>(HiddenFileSync.name) as HiddenFileSync;
|
||||
if (!addOnHiddenFileSync) {
|
||||
const msg =
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue.";
|
||||
const msg = translateMessage(
|
||||
"AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situation. Please report this issue."
|
||||
);
|
||||
Logger(msg, LOG_LEVEL_NOTICE);
|
||||
throw new Error(msg);
|
||||
}
|
||||
@@ -92,9 +95,9 @@
|
||||
}
|
||||
|
||||
const displays = {
|
||||
CONFIG: "Configuration",
|
||||
THEME: "Themes",
|
||||
SNIPPET: "Snippets",
|
||||
CONFIG: translateMessage("Configuration"),
|
||||
THEME: translateMessage("Themes"),
|
||||
SNIPPET: translateMessage("Snippets"),
|
||||
};
|
||||
async function scanAgain() {
|
||||
await addOn.scanAllConfigFiles(true);
|
||||
@@ -156,20 +159,20 @@
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle("Initial Action").setIsLabel(true));
|
||||
menu.addItem((item) => item.setTitle(translateMessage("Initial Action")).setIsLabel(true));
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`↑: Overwrite Remote`).onClick((e) => {
|
||||
item.setTitle(translateMessage("↑: Overwrite Remote")).onClick((e) => {
|
||||
applyAutomaticSync(key, "pushForce");
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(`↓: Overwrite Local`).onClick((e) => {
|
||||
item.setTitle(translateMessage("↓: Overwrite Local")).onClick((e) => {
|
||||
applyAutomaticSync(key, "pullForce");
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(`⇅: Use newer`).onClick((e) => {
|
||||
item.setTitle(translateMessage("⇅: Use newer")).onClick((e) => {
|
||||
applyAutomaticSync(key, "safe");
|
||||
});
|
||||
});
|
||||
@@ -201,10 +204,10 @@
|
||||
[MODE_SHINY]: ICON_EMOJI_FLAGGED,
|
||||
};
|
||||
const TITLES: { [key: number]: string } = {
|
||||
[MODE_SELECTIVE]: "Selective",
|
||||
[MODE_PAUSED]: "Ignore",
|
||||
[MODE_AUTOMATIC]: "Automatic",
|
||||
[MODE_SHINY]: "Flagged Selective",
|
||||
[MODE_SELECTIVE]: translateMessage("Selective"),
|
||||
[MODE_PAUSED]: translateMessage("Ignore"),
|
||||
[MODE_AUTOMATIC]: translateMessage("Automatic"),
|
||||
[MODE_SHINY]: translateMessage("Flagged Selective"),
|
||||
};
|
||||
const PREFIX_PLUGIN_ALL = "PLUGIN_ALL";
|
||||
const PREFIX_PLUGIN_DATA = "PLUGIN_DATA";
|
||||
@@ -329,28 +332,30 @@
|
||||
|
||||
<div class="buttonsWrap">
|
||||
<div class="buttons">
|
||||
<button on:click={() => scanAgain()}>Scan changes</button>
|
||||
<button on:click={() => replicate()}>Sync once</button>
|
||||
<button on:click={() => requestUpdate()}>Refresh</button>
|
||||
<button on:click={() => scanAgain()}>{translateMessage("Scan changes")}</button>
|
||||
<button on:click={() => replicate()}>{translateMessage("Sync once")}</button>
|
||||
<button on:click={() => requestUpdate()}>{translateMessage("Refresh")}</button>
|
||||
{#if isMaintenanceMode}
|
||||
<button on:click={() => requestReload()}>Reload</button>
|
||||
<button on:click={() => requestReload()}>{translateMessage("Reload")}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button on:click={() => selectAllNewest(true)}>Select All Shiny</button>
|
||||
<button on:click={() => selectAllNewest(false)}>{ICON_EMOJI_FLAGGED} Select Flagged Shiny</button>
|
||||
<button on:click={() => resetSelectNewest()}>Deselect all</button>
|
||||
<button on:click={() => applyAll()} class="mod-cta">Apply All Selected</button>
|
||||
<button on:click={() => selectAllNewest(true)}>{translateMessage("Select All Shiny")}</button>
|
||||
<button on:click={() => selectAllNewest(false)}
|
||||
>{ICON_EMOJI_FLAGGED} {translateMessage("Select Flagged Shiny")}</button
|
||||
>
|
||||
<button on:click={() => resetSelectNewest()}>{translateMessage("Deselect all")}</button>
|
||||
<button on:click={() => applyAll()} class="mod-cta">{translateMessage("Apply All Selected")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading">
|
||||
{#if loading || $pluginV2Progress !== 0}
|
||||
<span>Updating list...{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
|
||||
<span>{translateMessage("Updating list...")}{$pluginV2Progress == 0 ? "" : ` (${$pluginV2Progress})`}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="list">
|
||||
{#if list.length == 0}
|
||||
<div class="center">No Items.</div>
|
||||
<div class="center">{translateMessage("No Items.")}</div>
|
||||
{:else}
|
||||
{#each displayEntries as [key, label]}
|
||||
<div>
|
||||
@@ -382,7 +387,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<h3>Plugins</h3>
|
||||
<h3>{translateMessage("Plugins")}</h3>
|
||||
{#each pluginEntries as [name, listX]}
|
||||
{@const bindKeyAll = `${PREFIX_PLUGIN_ALL}/${name}`}
|
||||
{@const modeAll = automaticListDisp.get(bindKeyAll) ?? MODE_SELECTIVE}
|
||||
@@ -464,7 +469,7 @@
|
||||
>
|
||||
{getIcon(modeEtc)}
|
||||
</button>
|
||||
<span class="name">Other files</span>
|
||||
<span class="name">{translateMessage("Other files")}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
{#if modeEtc == MODE_SELECTIVE || modeEtc == MODE_SHINY}
|
||||
@@ -492,9 +497,9 @@
|
||||
{#if isMaintenanceMode}
|
||||
<div class="buttons">
|
||||
<div>
|
||||
<h3>Maintenance Commands</h3>
|
||||
<h3>{translateMessage("Maintenance Commands")}</h3>
|
||||
<div class="maintenancerow">
|
||||
<label for="">Delete All of </label>
|
||||
<label for="">{translateMessage("Delete All of")} </label>
|
||||
<select bind:value={deleteTerm}>
|
||||
{#each allTerms as term}
|
||||
<option value={term}>{term}</option>
|
||||
@@ -513,10 +518,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<label><span>Hide not applicable items</span><input type="checkbox" bind:checked={hideEven} /></label>
|
||||
<label
|
||||
><span>{translateMessage("Hide not applicable items")}</span><input
|
||||
type="checkbox"
|
||||
bind:checked={hideEven}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<label><span>Maintenance mode</span><input type="checkbox" bind:checked={isMaintenanceMode} /></label>
|
||||
<label
|
||||
><span>{translateMessage("Maintenance mode")}</span><input
|
||||
type="checkbox"
|
||||
bind:checked={isMaintenanceMode}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { FilePath, LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { decodeBinary, readString } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
|
||||
import { getDocData, isObjectDifferent, mergeObject } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
docs?: LoadedEntry[];
|
||||
@@ -115,7 +116,7 @@
|
||||
let newModes = [] as typeof modesSrc;
|
||||
|
||||
if (!hideLocal) {
|
||||
newModes.push(["", "Not now"]);
|
||||
newModes.push(["", translateMessage("Not now")]);
|
||||
newModes.push(["A", nameA || "A"]);
|
||||
}
|
||||
newModes.push(["B", nameB || "B"]);
|
||||
@@ -127,9 +128,9 @@
|
||||
|
||||
<h2>{filename}</h2>
|
||||
{#if !docA || !docB}
|
||||
<div class="message">Just for a minute, please!</div>
|
||||
<div class="message">{translateMessage("Just for a minute, please!")}</div>
|
||||
<div class="buttons">
|
||||
<button onclick={apply}>Dismiss</button>
|
||||
<button onclick={apply}>{translateMessage("Dismiss")}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="options">
|
||||
@@ -152,7 +153,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
NO PREVIEW
|
||||
{translateMessage("NO PREVIEW")}
|
||||
{/if}
|
||||
|
||||
<div class="infos">
|
||||
|
||||
@@ -57,6 +57,7 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d
|
||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
|
||||
|
||||
type HiddenFileInitialisationProgress = {
|
||||
@@ -1539,10 +1540,7 @@ Offline Changed files: ${files.length}`;
|
||||
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true),
|
||||
this.core.databaseFileAccess.getConflictedRevs(prefixedFileName),
|
||||
]);
|
||||
const liveRevisions = new Set([
|
||||
...(current && current._rev ? [current._rev] : []),
|
||||
...conflicts,
|
||||
]);
|
||||
const liveRevisions = new Set([...(current && current._rev ? [current._rev] : []), ...conflicts]);
|
||||
if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) {
|
||||
this._log(
|
||||
`Could not use hidden-file revision ${revision} of ${stripAllPrefixes(prefixedFileName)}; the selected revision is no longer live`,
|
||||
@@ -1834,15 +1832,7 @@ Offline Changed files: ${files.length}`;
|
||||
force = false
|
||||
): Promise<boolean> {
|
||||
return Boolean(
|
||||
await this.extractInternalFileFromDatabase(
|
||||
storageFilePath,
|
||||
force,
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
revision
|
||||
)
|
||||
await this.extractInternalFileFromDatabase(storageFilePath, force, undefined, true, false, true, revision)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1917,7 +1907,9 @@ Offline Changed files: ${files.length}`;
|
||||
private _allSuspendExtraSync(): Promise<boolean> {
|
||||
if (this.core.settings.syncInternalFiles) {
|
||||
this._log(
|
||||
"Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.",
|
||||
$msg(
|
||||
"Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them."
|
||||
),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.core.settings.syncInternalFiles = false;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
@@ -84,11 +85,11 @@
|
||||
}
|
||||
|
||||
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||
if (peer.isAccepted === true) return "ACCEPTED";
|
||||
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||
if (peer.isAccepted === false) return "DENIED";
|
||||
return "NEW";
|
||||
if (peer.isTemporaryAccepted === true) return translateMessage("ACCEPTED (in session)");
|
||||
if (peer.isAccepted === true) return translateMessage("ACCEPTED");
|
||||
if (peer.isTemporaryAccepted === false) return translateMessage("DENIED (in session)");
|
||||
if (peer.isAccepted === false) return translateMessage("DENIED");
|
||||
return translateMessage("NEW");
|
||||
}
|
||||
|
||||
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
@@ -102,7 +103,7 @@
|
||||
<P2PServerStatusCard {getLiveSyncReplicator} showBroadcastToggle={false} />
|
||||
|
||||
<div class="peers-section">
|
||||
<h3>Available Peers</h3>
|
||||
<h3>{translateMessage("Available Peers")}</h3>
|
||||
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||
<div class="peers-list">
|
||||
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||
@@ -126,14 +127,18 @@
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSync(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||
{syncingPeerId === peer.peerId
|
||||
? translateMessage("Syncing...")
|
||||
: translateMessage("Sync")}
|
||||
</button>
|
||||
<button
|
||||
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSyncThenClose(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Start Sync & Close"}
|
||||
{syncingPeerId === peer.peerId
|
||||
? translateMessage("Syncing...")
|
||||
: translateMessage("Start Sync & Close")}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -141,7 +146,9 @@
|
||||
disabled={syncingPeerId !== null}
|
||||
onclick={() => handleSyncThenClose(peer.peerId)}
|
||||
>
|
||||
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
|
||||
{syncingPeerId === peer.peerId
|
||||
? translateMessage("Syncing...")
|
||||
: translateMessage("Sync")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -149,16 +156,22 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else if serverInfo}
|
||||
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||
<p class="no-peers">
|
||||
{translateMessage("No devices available. Waiting for other devices to connect...")}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{#if rebuildMode}
|
||||
<button class="btn btn-cancel" onclick={onClose} disabled={syncingPeerId !== null}>Skip and close</button>
|
||||
<button class="btn btn-cancel" onclick={onClose} disabled={syncingPeerId !== null}
|
||||
>{translateMessage("Skip and close")}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="btn btn-cancel" onclick={onClose}>Close</button>
|
||||
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
|
||||
<button class="btn btn-cancel" onclick={onClose}>{translateMessage("Close")}</button>
|
||||
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}
|
||||
>{translateMessage("Close & Disconnect")}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount, setContext } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { AutoAccepting, DEFAULT_SETTINGS, type P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
AcceptedStatus,
|
||||
ConnectionStatus,
|
||||
type PeerStatus,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { P2PReplicatorPaneController } from "./P2PReplicatorPaneController";
|
||||
import type { P2PReplicatorPaneHost } from "./P2PReplicatorPaneHost";
|
||||
import PeerStatusRow from "@/features/P2PSync/P2PReplicator/PeerStatusRow.svelte";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
|
||||
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
type PeerInfo,
|
||||
type P2PServerInfo,
|
||||
@@ -20,16 +20,16 @@
|
||||
import { $msg as _msg } from "@/common/translation";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { generateP2PRoomId } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
|
||||
interface Props {
|
||||
getCmdSync: () => P2PReplicatorPaneController;
|
||||
core: Pick<LiveSyncBaseCore, "services">;
|
||||
host: P2PReplicatorPaneHost;
|
||||
}
|
||||
|
||||
let { getCmdSync, core }: Props = $props();
|
||||
setContext("getReplicator", () => getCmdSync());
|
||||
const currentSettings = () => core.services.setting.currentSettings() as P2PSyncSetting;
|
||||
let { host }: Props = $props();
|
||||
let services = $derived(host.services);
|
||||
let events = $derived(services.context.events);
|
||||
const currentSettings = () => services.setting.currentSettings() as P2PSyncSetting;
|
||||
const currentReplicator = () => host.p2p.replicator;
|
||||
const initialSettings = { ...currentSettings() } as P2PSyncSetting;
|
||||
|
||||
let settings = $state<P2PSyncSetting>(initialSettings);
|
||||
@@ -81,7 +81,7 @@
|
||||
// P2P_AutoStart: eAutoStart,
|
||||
// P2P_AutoBroadcast: eAutoBroadcast,
|
||||
// };
|
||||
await core.services.setting.applyPartial(
|
||||
await services.setting.applyPartial(
|
||||
{
|
||||
P2P_Enabled: eP2PEnabled,
|
||||
P2P_relays: eRelay,
|
||||
@@ -94,7 +94,7 @@
|
||||
},
|
||||
true
|
||||
);
|
||||
core.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
||||
services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
|
||||
deviceName = eDeviceName;
|
||||
}
|
||||
async function revert() {
|
||||
@@ -113,7 +113,7 @@
|
||||
const applyLoadSettings = (d: P2PSyncSetting, force: boolean) => {
|
||||
if (force) {
|
||||
const initDeviceName =
|
||||
core.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? core.services.vault.getVaultName();
|
||||
services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? services.vault.getVaultName();
|
||||
deviceName = initDeviceName;
|
||||
eDeviceName = initDeviceName;
|
||||
}
|
||||
@@ -131,21 +131,22 @@
|
||||
settings = d;
|
||||
};
|
||||
onMount(() => {
|
||||
const r = eventHub.onEvent("setting-saved", async (d) => {
|
||||
const r = events.onEvent("setting-saved", async (d) => {
|
||||
applyLoadSettings(d, false);
|
||||
closeServer();
|
||||
});
|
||||
const rx = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
|
||||
const rx = events.onEvent(EVENT_LAYOUT_READY, () => {
|
||||
applyLoadSettings(currentSettings(), true);
|
||||
});
|
||||
const r2 = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
const r2 = events.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
advertisements = status?.knownAdvertisements ?? [];
|
||||
});
|
||||
const r3 = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
const r3 = events.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
});
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
applyLoadSettings(currentSettings(), true);
|
||||
events.emitEvent(EVENT_REQUEST_STATUS);
|
||||
return () => {
|
||||
r();
|
||||
rx();
|
||||
@@ -222,22 +223,22 @@
|
||||
}
|
||||
|
||||
async function openServer() {
|
||||
await getCmdSync().open();
|
||||
await currentReplicator().open();
|
||||
}
|
||||
async function closeServer() {
|
||||
await getCmdSync().close();
|
||||
await currentReplicator().close();
|
||||
}
|
||||
function startBroadcasting() {
|
||||
void getCmdSync().enableBroadcastChanges();
|
||||
currentReplicator().enableBroadcastChanges();
|
||||
}
|
||||
function stopBroadcasting() {
|
||||
void getCmdSync().disableBroadcastChanges();
|
||||
currentReplicator().disableBroadcastChanges();
|
||||
}
|
||||
|
||||
const initialDialogStatusKey = `p2p-dialog-status`;
|
||||
const getDialogStatus = () => {
|
||||
try {
|
||||
const initialDialogStatus = JSON.parse(core.services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
|
||||
const initialDialogStatus = JSON.parse(services.config.getSmallConfig(initialDialogStatusKey) ?? "{}") as {
|
||||
notice?: boolean;
|
||||
setting?: boolean;
|
||||
};
|
||||
@@ -254,15 +255,15 @@
|
||||
notice: isNoticeOpened,
|
||||
setting: isSettingOpened,
|
||||
};
|
||||
core.services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
services.config.setSmallConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
|
||||
});
|
||||
let isObsidian = $derived.by(() => {
|
||||
return core.services.API.getPlatform() === "obsidian";
|
||||
return services.API.getPlatform() === "obsidian";
|
||||
});
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<h1>Peer to Peer Replicator</h1>
|
||||
<h1>{_msg("Peer to Peer Replicator")}</h1>
|
||||
<details bind:open={isNoticeOpened}>
|
||||
<summary>{_msg("P2P.Note.Summary")}</summary>
|
||||
<p class="important">{_msg("P2P.Note.important_note")}</p>
|
||||
@@ -273,23 +274,23 @@
|
||||
<p>{paragraph}</p>
|
||||
{/each}
|
||||
</details>
|
||||
<h2>Connection Settings</h2>
|
||||
<h2>{_msg("Connection Settings")}</h2>
|
||||
{#if isObsidian}
|
||||
You can configure in the Obsidian Plugin Settings.
|
||||
{_msg("You can configure in the Obsidian Plugin Settings.")}
|
||||
{:else}
|
||||
<details bind:open={isSettingOpened}>
|
||||
<summary>{eRelay}</summary>
|
||||
<table class="settings">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th> Enable P2P Replicator </th>
|
||||
<th>{_msg("Enable P2P Replicator")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isP2PEnabledModified }}>
|
||||
<input type="checkbox" bind:checked={eP2PEnabled} />
|
||||
</label>
|
||||
</td>
|
||||
</tr><tr>
|
||||
<th> Relay settings </th>
|
||||
<th>{_msg("Relay settings")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRelayModified }}>
|
||||
<input
|
||||
@@ -298,12 +299,12 @@
|
||||
bind:value={eRelay}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button onclick={() => useDefaultRelay()}> Use vrtmrz's relay </button>
|
||||
<button onclick={() => useDefaultRelay()}>{_msg("Use vrtmrz's relay")}</button>
|
||||
</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Room ID </th>
|
||||
<th>{_msg("Room ID")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isRoomIdModified }}>
|
||||
<input
|
||||
@@ -314,31 +315,32 @@
|
||||
spellcheck="false"
|
||||
autocorrect="off"
|
||||
/>
|
||||
<button onclick={() => chooseRandom()}> Use Random Number </button>
|
||||
<button onclick={() => chooseRandom()}>{_msg("Use Random Number")}</button>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
This can isolate your connections between devices. Use the same Room ID for the same
|
||||
devices.</small
|
||||
<small
|
||||
>{_msg(
|
||||
"This can isolate your connections between devices. Use the same Room ID for the same devices."
|
||||
)}</small
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Password </th>
|
||||
<th>{_msg("Password")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isPasswordModified }}>
|
||||
<input type="password" placeholder="password" bind:value={ePassword} />
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
This password is used to encrypt the connection. Use something long enough.
|
||||
{_msg("This password is used to encrypt the connection. Use something long enough.")}
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> This device name </th>
|
||||
<th>{_msg("This device name")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isDeviceNameModified }}>
|
||||
<input
|
||||
@@ -350,14 +352,15 @@
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
Device name to identify the device. Please use shorter one for the stable peer
|
||||
detection, i.e., "iphone-16" or "macbook-2021".
|
||||
{_msg(
|
||||
'Device name to identify the device. Please use shorter one for the stable peer detection, i.e., "iphone-16" or "macbook-2021".'
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Auto Connect </th>
|
||||
<th>{_msg("Auto Connect")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoStartModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoStart} />
|
||||
@@ -365,7 +368,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th> Start change-broadcasting on Connect </th>
|
||||
<th>{_msg("Start change-broadcasting on Connect")}</th>
|
||||
<td>
|
||||
<label class={{ "is-dirty": isAutoBroadcastModified }}>
|
||||
<input type="checkbox" bind:checked={eAutoBroadcast} />
|
||||
@@ -382,39 +385,43 @@
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}>Save and Apply</button>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>Revert changes</button>
|
||||
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}
|
||||
>{_msg("Save and Apply")}</button
|
||||
>
|
||||
<button disabled={!isAnyModified} class="button" onclick={revert}>{_msg("Revert changes")}</button>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<h2>Signaling Server Connection</h2>
|
||||
<h2>{_msg("Signaling Server Connection")}</h2>
|
||||
<div>
|
||||
{#if !isConnected}
|
||||
<p>No Connection</p>
|
||||
<p>{_msg("No Connection")}</p>
|
||||
{:else}
|
||||
<p>Connected to Signaling Server (as Peer ID: {serverPeerId})</p>
|
||||
<p>{_msg("Connected to Signaling Server (as Peer ID: ${peerId})", { peerId: serverPeerId })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if !isConnected}
|
||||
<button onclick={openServer}>Connect</button>
|
||||
<button onclick={openServer}>{_msg("Connect")}</button>
|
||||
{:else}
|
||||
<button onclick={closeServer}>Disconnect</button>
|
||||
<button onclick={closeServer}>{_msg("Disconnect")}</button>
|
||||
{#if replicatorInfo?.isBroadcasting !== undefined}
|
||||
{#if replicatorInfo?.isBroadcasting}
|
||||
<button onclick={stopBroadcasting}>Stop Broadcasting</button>
|
||||
<button onclick={stopBroadcasting}>{_msg("Stop Broadcasting")}</button>
|
||||
{:else}
|
||||
<button onclick={startBroadcasting}>Start Broadcasting</button>
|
||||
<button onclick={startBroadcasting}>{_msg("Start Broadcasting")}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<details>
|
||||
<summary>Broadcasting?</summary>
|
||||
<summary>{_msg("Broadcasting?")}</summary>
|
||||
<p>
|
||||
<small>
|
||||
If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which
|
||||
detects this will start the replication for fetching. <br />
|
||||
However, This should not be enabled if you want to increase your secrecy more.
|
||||
{_msg(
|
||||
"If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching."
|
||||
)}
|
||||
<br />
|
||||
{_msg("However, This should not be enabled if you want to increase your secrecy more.")}
|
||||
</small>
|
||||
</p>
|
||||
</details>
|
||||
@@ -423,18 +430,18 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Peers</h2>
|
||||
<h2>{_msg("Peers")}</h2>
|
||||
<table class="peers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Action</th>
|
||||
<th>Command</th>
|
||||
<th>{_msg("Name")}</th>
|
||||
<th>{_msg("Action")}</th>
|
||||
<th>{_msg("Command")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each peers as peer}
|
||||
<PeerStatusRow peerStatus={peer}></PeerStatusRow>
|
||||
<PeerStatusRow peerStatus={peer} p2p={host.p2p} showPeerMenu={host.showPeerMenu}></PeerStatusRow>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type {
|
||||
AcceptanceDecision,
|
||||
RevokeAcceptanceDecision,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
|
||||
/** The operations used by the shared P2P pane, independent of its replicator implementation. */
|
||||
export interface P2PReplicatorPaneController {
|
||||
open(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
enableBroadcastChanges(): void;
|
||||
disableBroadcastChanges(): void;
|
||||
makeDecision(decision: AcceptanceDecision): Promise<void>;
|
||||
revokeDecision(decision: RevokeAcceptanceDecision): Promise<void>;
|
||||
watchPeer(peerId: string): void;
|
||||
unwatchPeer(peerId: string): void;
|
||||
sync(peerId: string, showNotice?: boolean): Promise<unknown>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RequiredServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
|
||||
export type P2PReplicatorHandle = Pick<UseP2PReplicatorResult, "replicator">;
|
||||
|
||||
/** Host capabilities consumed by the shared P2P pane. */
|
||||
export interface P2PReplicatorPaneHost {
|
||||
readonly services: RequiredServices<"API" | "config" | "setting" | "vault">;
|
||||
readonly p2p: P2PReplicatorHandle;
|
||||
readonly showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import { Menu, WorkspaceLeaf } from "@/deps.ts";
|
||||
import ReplicatorPaneComponent from "./P2PReplicatorPane.svelte";
|
||||
import { mount } from "svelte";
|
||||
import { SvelteItemView } from "@/common/SvelteItemView.ts";
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { LOG_LEVEL_NOTICE, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { EVENT_P2P_PEER_SHOW_EXTRA_MENU, type PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
export const VIEW_TYPE_P2P = "p2p-replicator";
|
||||
@@ -127,46 +126,45 @@ And you can also drop the local database to rebuild from the remote device.`,
|
||||
super(leaf);
|
||||
this.core = core;
|
||||
this._p2pResult = p2pResult;
|
||||
eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => {
|
||||
if (this.m) {
|
||||
this.m.hide();
|
||||
}
|
||||
this.m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🔧 Get Configuration").onClick(async () => {
|
||||
await this.getRemoteConfig(peer);
|
||||
});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.watchOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnReplicationCommand");
|
||||
})
|
||||
.setIcon(mark);
|
||||
}
|
||||
|
||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||
this.m?.hide();
|
||||
this.m = new Menu()
|
||||
.addItem((item) => item.setTitle("📥 Only Fetch").onClick(() => this.replicateFrom(peer)))
|
||||
.addItem((item) => item.setTitle("📤 Only Send").onClick(() => this.replicateTo(peer)))
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🔧 Get Configuration").onClick(async () => {
|
||||
await this.getRemoteConfig(peer);
|
||||
});
|
||||
this.m.showAtPosition({ x: event.x, y: event.y });
|
||||
});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.watchOnConnect ? "checkmark" : null;
|
||||
item.setTitle("Toggle Watch on connect")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "watchOnConnect");
|
||||
})
|
||||
.setIcon(mark);
|
||||
})
|
||||
.addItem((item) => {
|
||||
const mark = peer.syncOnReplicationCommand ? "checkmark" : null;
|
||||
item.setTitle("Toggle Sync on `Replicate now` command")
|
||||
.onClick(async () => {
|
||||
await this.toggleProp(peer, "syncOnReplicationCommand");
|
||||
})
|
||||
.setIcon(mark);
|
||||
});
|
||||
void this.m.showAtPosition({ x: event.x, y: event.y });
|
||||
}
|
||||
|
||||
getViewType() {
|
||||
@@ -187,8 +185,11 @@ And you can also drop the local database to rebuild from the remote device.`,
|
||||
return mount(ReplicatorPaneComponent, {
|
||||
target: target,
|
||||
props: {
|
||||
getCmdSync: () => this._p2pResult.replicator,
|
||||
core: this.core,
|
||||
host: {
|
||||
services: this.core.services,
|
||||
p2p: this._p2pResult,
|
||||
showPeerMenu: (peer: PeerStatus, event: MouseEvent) => this.showPeerMenu(peer, event),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,40 +95,40 @@
|
||||
</script>
|
||||
|
||||
<div class="server-status">
|
||||
<h3>Signalling Status</h3>
|
||||
<h3>{translateMessage("Signalling Status")}</h3>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Connection:</span>
|
||||
<span>{translateMessage("Connection:")}</span>
|
||||
<span class="status-value {isConnected ? 'connected' : 'disconnected'}">
|
||||
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
|
||||
{isConnected ? translateMessage("🟢 Connected") : translateMessage("🔴 Disconnected")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item status-action">
|
||||
{#if !isConnected}
|
||||
<button onclick={onOpenConnection}>Open connection</button>
|
||||
<button onclick={onOpenConnection}>{translateMessage("Open connection")}</button>
|
||||
{:else}
|
||||
<button onclick={onDisconnect}>Disconnect</button>
|
||||
<button onclick={onDisconnect}>{translateMessage("Disconnect")}</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if serverInfo}
|
||||
<div class="status-item">
|
||||
<span>Room ID suffix:</span>
|
||||
<span class="room-suffix-display" title={roomSuffix || "Not configured"}>
|
||||
<span>{translateMessage("Room ID suffix:")}</span>
|
||||
<span class="room-suffix-display" title={roomSuffix || translateMessage("Not configured")}>
|
||||
{roomSuffix || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Peer ID:</span>
|
||||
<span>{translateMessage("Peer ID:")}</span>
|
||||
<span class="peer-id-display" title={serverInfo.serverPeerId}>
|
||||
{serverInfo.serverPeerId.slice(0, 12)}...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<span>Devices:</span>
|
||||
<span>{translateMessage("Devices:")}</span>
|
||||
<span>{serverInfo.knownAdvertisements.length}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -146,7 +146,7 @@
|
||||
? translateMessage("Stop announcing changes")
|
||||
: translateMessage("Start announcing changes")}
|
||||
>
|
||||
{isBroadcasting ? '📡 On' : '📡 Off'}
|
||||
{isBroadcasting ? translateMessage("📡 On") : translateMessage("📡 Off")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -154,39 +154,39 @@
|
||||
{#if core}
|
||||
<div class="status-item status-action diag-toggle-row">
|
||||
<label class="broadcast-label" for="diag-toggle">
|
||||
🕵️ Diag
|
||||
{translateMessage("🕵️ Diag")}
|
||||
</label>
|
||||
<button
|
||||
id="diag-toggle"
|
||||
class="broadcast-button {useDiagRTC ? 'is-on' : 'is-off'}"
|
||||
onclick={toggleDiagRTC}
|
||||
title={useDiagRTC
|
||||
? 'Diagnostic RTCPeerConnection is enabled'
|
||||
: 'Use Diagnostic RTCPeerConnection for statistics'}
|
||||
? translateMessage("Diagnostic RTCPeerConnection is enabled")
|
||||
: translateMessage("Use Diagnostic RTCPeerConnection for statistics")}
|
||||
>
|
||||
{useDiagRTC ? 'On' : 'Off'}
|
||||
{useDiagRTC ? translateMessage("On") : translateMessage("Off")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo}
|
||||
<div class="diag-section">
|
||||
<h4>Stats</h4>
|
||||
<h4>{translateMessage("Stats")}</h4>
|
||||
<div class="diag-grid">
|
||||
<div class="diag-item">
|
||||
<span>Incoming:</span>
|
||||
<span>{translateMessage("Incoming:")}</span>
|
||||
<span>{serverInfo.diag.totalNewConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Connected:</span>
|
||||
<span>{translateMessage("Connected:")}</span>
|
||||
<span>{serverInfo.diag.totalSuccessfulConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Failed:</span>
|
||||
<span>{translateMessage("Failed:")}</span>
|
||||
<span>{serverInfo.diag.totalFailedConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Closed:</span>
|
||||
<span>{translateMessage("Closed:")}</span>
|
||||
<span>{serverInfo.diag.totalClosedConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,11 +170,11 @@
|
||||
});
|
||||
|
||||
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||
if (peer.isAccepted === true) return "ACCEPTED";
|
||||
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||
if (peer.isAccepted === false) return "DENIED";
|
||||
return "NEW";
|
||||
if (peer.isTemporaryAccepted === true) return translateMessage("ACCEPTED (in session)");
|
||||
if (peer.isAccepted === true) return translateMessage("ACCEPTED");
|
||||
if (peer.isTemporaryAccepted === false) return translateMessage("DENIED (in session)");
|
||||
if (peer.isAccepted === false) return translateMessage("DENIED");
|
||||
return translateMessage("NEW");
|
||||
}
|
||||
|
||||
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
@@ -409,7 +409,7 @@
|
||||
|
||||
<div class="p2p-container">
|
||||
<div class="pane-header">
|
||||
<h2>P2P Status</h2>
|
||||
<h2>{translateMessage("P2P Status")}</h2>
|
||||
<div class="pane-header-actions">
|
||||
<div class="remote-picker-wrap">
|
||||
<select
|
||||
@@ -417,11 +417,11 @@
|
||||
value={selectedP2PRemoteConfigurationId}
|
||||
onchange={onP2PRemoteSelected}
|
||||
disabled={selectingP2PRemote}
|
||||
aria-label="Select active P2P remote"
|
||||
title="Select active P2P remote"
|
||||
aria-label={translateMessage("Select active P2P remote")}
|
||||
title={translateMessage("Select active P2P remote")}
|
||||
>
|
||||
{#if p2pRemoteOptions.length === 0}
|
||||
<option value="">Select P2P remote...</option>
|
||||
<option value="">{translateMessage("Select P2P remote...")}</option>
|
||||
{/if}
|
||||
{#each p2pRemoteOptions as option}
|
||||
<option value={option.id}>
|
||||
@@ -432,8 +432,8 @@
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={() => createAndSelectP2PRemote()}
|
||||
title="Create P2P remote"
|
||||
aria-label="Create P2P remote"
|
||||
title={translateMessage("Create P2P remote")}
|
||||
aria-label={translateMessage("Create P2P remote")}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
@@ -441,8 +441,8 @@
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={openConnectionSettings}
|
||||
title="Open P2P Setup..."
|
||||
aria-label="Open P2P Setup..."
|
||||
title={translateMessage("Open P2P Setup...")}
|
||||
aria-label={translateMessage("Open P2P Setup...")}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
@@ -450,15 +450,17 @@
|
||||
</div>
|
||||
|
||||
{#if !canEditP2PSettings()}
|
||||
<p class="warning-line">Please select an active P2P remote configuration to change P2P sync targets.</p>
|
||||
<p class="warning-line">
|
||||
{translateMessage("Please select an active P2P remote configuration to change P2P sync targets.")}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<P2PServerStatusCard {getLiveSyncReplicator} {core} />
|
||||
|
||||
<div class="peers-section">
|
||||
<div class="peers-header">
|
||||
<h3>Detected Peers</h3>
|
||||
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
|
||||
<h3>{translateMessage("Detected Peers")}</h3>
|
||||
<button class="refresh" onclick={requestServerStatus}>{translateMessage("Refresh")}</button>
|
||||
</div>
|
||||
|
||||
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||
@@ -470,7 +472,11 @@
|
||||
{peer.name} :
|
||||
<span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
|
||||
{#if isCommunicating(peer.peerId)}
|
||||
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
|
||||
<span
|
||||
class="comm-icon"
|
||||
title={translateMessage("Communicating")}
|
||||
aria-label={translateMessage("Communicating")}>📡</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="peer-meta">
|
||||
@@ -486,8 +492,12 @@
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId ? "Replicating..." : "Replicate now"}
|
||||
aria-label={replicatingPeerId === peer.peerId ? "Replicating" : "Replicate now"}
|
||||
title={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating...")
|
||||
: translateMessage("Replicate now")}
|
||||
aria-label={replicatingPeerId === peer.peerId
|
||||
? translateMessage("Replicating")
|
||||
: translateMessage("Replicate now")}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
@@ -497,7 +507,7 @@
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
Revoke
|
||||
{translateMessage("Revoke")}
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button"
|
||||
@@ -534,11 +544,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">PERMANENT</span>
|
||||
<span class="decision-label">{translateMessage("PERMANENT")}</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title="Allow permanently"
|
||||
aria-label="Allow permanently"
|
||||
title={translateMessage("Allow permanently")}
|
||||
aria-label={translateMessage("Allow permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
@@ -546,8 +556,8 @@
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title="Deny permanently"
|
||||
aria-label="Deny permanently"
|
||||
title={translateMessage("Deny permanently")}
|
||||
aria-label={translateMessage("Deny permanently")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
@@ -555,11 +565,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">SESSION</span>
|
||||
<span class="decision-label">{translateMessage("SESSION")}</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title="Allow in session"
|
||||
aria-label="Allow in session"
|
||||
title={translateMessage("Allow in session")}
|
||||
aria-label={translateMessage("Allow in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
@@ -567,8 +577,8 @@
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title="Deny in session"
|
||||
aria-label="Deny in session"
|
||||
title={translateMessage("Deny in session")}
|
||||
aria-label={translateMessage("Deny in session")}
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
@@ -582,7 +592,7 @@
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
Revoke
|
||||
{translateMessage("Revoke")}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -590,9 +600,11 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else if serverInfo}
|
||||
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||
<p class="no-peers">
|
||||
{translateMessage("No devices available. Waiting for other devices to connect...")}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="no-peers">Fetching status...</p>
|
||||
<p class="no-peers">{translateMessage("Fetching status...")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { AcceptedStatus, type PeerStatus } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { P2PReplicatorPaneController } from "./P2PReplicatorPaneController";
|
||||
import { eventHub } from "@/common/events";
|
||||
import { EVENT_P2P_PEER_SHOW_EXTRA_MENU } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { P2PReplicatorHandle } from "./P2PReplicatorPaneHost";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
interface Props {
|
||||
peerStatus: PeerStatus;
|
||||
p2p: P2PReplicatorHandle;
|
||||
showPeerMenu?: (peer: PeerStatus, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let { peerStatus }: Props = $props();
|
||||
let { peerStatus, p2p, showPeerMenu }: Props = $props();
|
||||
let peer = $derived(peerStatus);
|
||||
const currentReplicator = () => p2p.replicator;
|
||||
|
||||
function select<T extends string | number | symbol, U>(d: T, cond: Record<T, U>): U;
|
||||
function select<T extends string | number | symbol, U, V>(d: T, cond: Record<T, U>, def: V): U | V;
|
||||
function select<T extends string | number | symbol, U>(d: T, cond: Record<T, U>, def?: U): U | undefined {
|
||||
function select<T extends PropertyKey, U, V = undefined>(
|
||||
d: T,
|
||||
cond: Partial<Record<T, U>>,
|
||||
def: V | undefined = undefined
|
||||
): U | V | undefined {
|
||||
return d in cond ? cond[d] : def;
|
||||
}
|
||||
|
||||
@@ -25,6 +28,11 @@
|
||||
peer.isSending ? ["SENDING"] : [],
|
||||
].flat()
|
||||
);
|
||||
const chipLabels: Record<string, string> = {
|
||||
WATCHING: translateMessage("WATCHING"),
|
||||
FETCHING: translateMessage("FETCHING"),
|
||||
SENDING: translateMessage("SENDING"),
|
||||
};
|
||||
let acceptedStatusChip = $derived.by(() =>
|
||||
select(
|
||||
peer.accepted.toString(),
|
||||
@@ -36,8 +44,15 @@
|
||||
[AcceptedStatus.UNKNOWN]: "NEW",
|
||||
},
|
||||
""
|
||||
)
|
||||
) ?? ""
|
||||
);
|
||||
const acceptedStatusLabels: Record<string, string> = {
|
||||
ACCEPTED: translateMessage("ACCEPTED"),
|
||||
"ACCEPTED (in session)": translateMessage("ACCEPTED (in session)"),
|
||||
"DENIED (in session)": translateMessage("DENIED (in session)"),
|
||||
DENIED: translateMessage("DENIED"),
|
||||
NEW: translateMessage("NEW"),
|
||||
};
|
||||
const classList = {
|
||||
["SENDING"]: "connected",
|
||||
["FETCHING"]: "connected",
|
||||
@@ -57,7 +72,7 @@
|
||||
let isNew = $derived.by(() => peer.accepted === AcceptedStatus.UNKNOWN);
|
||||
|
||||
function makeDecision(isAccepted: boolean, isTemporary: boolean) {
|
||||
getReplicator().makeDecision({
|
||||
currentReplicator().makeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
decision: isAccepted,
|
||||
@@ -65,39 +80,37 @@
|
||||
});
|
||||
}
|
||||
function revokeDecision() {
|
||||
getReplicator().revokeDecision({
|
||||
currentReplicator().revokeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
});
|
||||
}
|
||||
const getReplicator = getContext<() => P2PReplicatorPaneController>("getReplicator");
|
||||
|
||||
const peerAttrLabels = $derived.by(() => {
|
||||
const attrs = [];
|
||||
if (peer.syncOnConnect) {
|
||||
attrs.push("✔ SYNC");
|
||||
attrs.push(translateMessage("✔ SYNC"));
|
||||
}
|
||||
if (peer.watchOnConnect) {
|
||||
attrs.push("✔ WATCH");
|
||||
attrs.push(translateMessage("✔ WATCH"));
|
||||
}
|
||||
if (peer.syncOnReplicationCommand) {
|
||||
attrs.push("✔ SELECT");
|
||||
attrs.push(translateMessage("✔ SELECT"));
|
||||
}
|
||||
return attrs;
|
||||
});
|
||||
function startWatching() {
|
||||
getReplicator().watchPeer(peer.peerId);
|
||||
currentReplicator().watchPeer(peer.peerId);
|
||||
}
|
||||
function stopWatching() {
|
||||
getReplicator().unwatchPeer(peer.peerId);
|
||||
currentReplicator().unwatchPeer(peer.peerId);
|
||||
}
|
||||
|
||||
function sync() {
|
||||
void getReplicator().sync(peer.peerId, false);
|
||||
void currentReplicator().sync(peer.peerId, false);
|
||||
}
|
||||
|
||||
function moreMenu(evt: MouseEvent) {
|
||||
eventHub.emitEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, { peer, event: evt });
|
||||
showPeerMenu?.(peer, evt);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -113,12 +126,14 @@
|
||||
</div>
|
||||
<div class="status-chips">
|
||||
<div class="row">
|
||||
<span class="chip {select(acceptedStatusChip, classList)}">{acceptedStatusChip}</span>
|
||||
<span class="chip {select(acceptedStatusChip, classList)}"
|
||||
>{acceptedStatusLabels[acceptedStatusChip] ?? acceptedStatusChip}</span
|
||||
>
|
||||
</div>
|
||||
{#if isAccepted}
|
||||
<div class="row">
|
||||
{#each statusChips as chip}
|
||||
<span class="chip {select(chip, classList)}">{chip}</span>
|
||||
<span class="chip {select(chip, classList)}">{chipLabels[chip] ?? chip}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -134,15 +149,25 @@
|
||||
<div class="row">
|
||||
{#if isNew}
|
||||
{#if !isAccepted}
|
||||
<button class="button" onclick={() => makeDecision(true, true)}>Accept in session</button>
|
||||
<button class="button mod-cta" onclick={() => makeDecision(true, false)}>Accept</button>
|
||||
<button class="button" onclick={() => makeDecision(true, true)}
|
||||
>{translateMessage("Accept in session")}</button
|
||||
>
|
||||
<button class="button mod-cta" onclick={() => makeDecision(true, false)}
|
||||
>{translateMessage("Accept")}</button
|
||||
>
|
||||
{/if}
|
||||
{#if !isDenied}
|
||||
<button class="button" onclick={() => makeDecision(false, true)}>Deny in session</button>
|
||||
<button class="button mod-warning" onclick={() => makeDecision(false, false)}>Deny</button>
|
||||
<button class="button" onclick={() => makeDecision(false, true)}
|
||||
>{translateMessage("Deny in session")}</button
|
||||
>
|
||||
<button class="button mod-warning" onclick={() => makeDecision(false, false)}
|
||||
>{translateMessage("Deny")}</button
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="button mod-warning" onclick={() => revokeDecision()}>Revoke</button>
|
||||
<button class="button mod-warning" onclick={() => revokeDecision()}
|
||||
>{translateMessage("Revoke")}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,11 +180,13 @@
|
||||
<!-- <button class="button" onclick={replicateFrom} disabled={peer.isFetching}>📥</button>
|
||||
<button class="button" onclick={replicateTo} disabled={peer.isSending}>📤</button> -->
|
||||
{#if peer.isWatching}
|
||||
<button class="button" onclick={stopWatching}>Stop ⚡</button>
|
||||
<button class="button" onclick={stopWatching}>{translateMessage("Stop ⚡")}</button>
|
||||
{:else}
|
||||
<button class="button" onclick={startWatching} title="live">⚡</button>
|
||||
<button class="button" onclick={startWatching} title={translateMessage("live")}>⚡</button>
|
||||
{/if}
|
||||
{#if showPeerMenu}
|
||||
<button class="button" onclick={moreMenu}>...</button>
|
||||
{/if}
|
||||
<button class="button" onclick={moreMenu}>...</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -24,9 +24,16 @@ import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
|
||||
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
|
||||
const REPROCESS_BATCH_SIZE = 100;
|
||||
type LocalApplicationActivityOwner = {
|
||||
runBoundedLocalApplicationActivity<T>(
|
||||
task: () => T | PromiseLike<T>,
|
||||
options?: { label?: string }
|
||||
): Promise<T>;
|
||||
};
|
||||
type ReplicateResultProcessorState = {
|
||||
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||
@@ -67,9 +74,11 @@ export class ReplicateResultProcessor {
|
||||
|
||||
public suspend() {
|
||||
this._suspended = true;
|
||||
this.updateProcessingActivity();
|
||||
}
|
||||
public resume() {
|
||||
this._suspended = false;
|
||||
this.updateProcessingActivity();
|
||||
fireAndForget(() => this.runProcessQueue());
|
||||
}
|
||||
|
||||
@@ -251,6 +260,40 @@ export class ReplicateResultProcessor {
|
||||
*/
|
||||
private _processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
|
||||
|
||||
private _processingActivity?: Promise<void>;
|
||||
private _processingActivityDone?: PromiseWithResolvers<void>;
|
||||
|
||||
private updateProcessingActivity() {
|
||||
if (this.isSuspended) {
|
||||
this._processingActivityDone?.resolve();
|
||||
return;
|
||||
}
|
||||
const hasPendingDocuments = this._queuedChanges.length > 0 || this._processingChanges.length > 0;
|
||||
if (!hasPendingDocuments) {
|
||||
this._processingActivityDone?.resolve();
|
||||
return;
|
||||
}
|
||||
if (this._processingActivity) return;
|
||||
|
||||
const activityDone = promiseWithResolvers<void>();
|
||||
this._processingActivityDone = activityDone;
|
||||
const activityOwner = this.services.replicator as typeof this.services.replicator &
|
||||
Partial<LocalApplicationActivityOwner>;
|
||||
this._processingActivity = (
|
||||
activityOwner.runBoundedLocalApplicationActivity
|
||||
? activityOwner.runBoundedLocalApplicationActivity(() => activityDone.promise, {
|
||||
label: "replicated-document-application",
|
||||
})
|
||||
: activityDone.promise
|
||||
)
|
||||
.catch((error) => this.logError(error))
|
||||
.finally(() => {
|
||||
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
|
||||
this._processingActivity = undefined;
|
||||
this.updateProcessingActivity();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the given document change for processing.
|
||||
* @param doc Document change to enqueue
|
||||
@@ -278,6 +321,7 @@ export class ReplicateResultProcessor {
|
||||
}
|
||||
// Enqueue the change
|
||||
this._queuedChanges.push(doc);
|
||||
this.updateProcessingActivity();
|
||||
this.triggerTakeSnapshot();
|
||||
this.triggerProcessQueue();
|
||||
}
|
||||
@@ -385,7 +429,19 @@ export class ReplicateResultProcessor {
|
||||
} finally {
|
||||
// Remove from processing queue
|
||||
this._processingChanges = this._processingChanges.filter((e) => e !== change);
|
||||
this.triggerTakeSnapshot();
|
||||
try {
|
||||
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
|
||||
try {
|
||||
await this._takeSnapshot();
|
||||
} catch (error) {
|
||||
this.logError(error);
|
||||
}
|
||||
} else {
|
||||
this.triggerTakeSnapshot();
|
||||
}
|
||||
} finally {
|
||||
this.updateProcessingActivity();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
|
||||
describe("ReplicateResultProcessor target-filter reprocessing", () => {
|
||||
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||
return {
|
||||
_id: id,
|
||||
_rev: "1-test",
|
||||
path: `${id}.md`,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 1,
|
||||
children: [],
|
||||
datatype: "plain",
|
||||
type: "plain",
|
||||
eden: {},
|
||||
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||
}
|
||||
|
||||
type SetupOptions = {
|
||||
processSynchroniseResult?: (entry: unknown) => Promise<void>;
|
||||
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
|
||||
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
|
||||
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
||||
const core = {
|
||||
services: {
|
||||
appLifecycle: { isReady: true, isSuspended: () => false },
|
||||
path: { getPath: (entry: { path: string }) => entry.path },
|
||||
replication: {
|
||||
databaseQueueCount: reactiveSource(0),
|
||||
storageApplyingCount: reactiveSource(0),
|
||||
replicationResultCount: reactiveSource(0),
|
||||
processVirtualDocument: vi.fn(async () => false),
|
||||
processOptionalSynchroniseResult: vi.fn(async () => false),
|
||||
processSynchroniseResult,
|
||||
},
|
||||
replicator: { runBoundedLocalApplicationActivity },
|
||||
vault: {
|
||||
isTargetFile: vi.fn(async () => true),
|
||||
isFileSizeTooLarge: vi.fn(() => false),
|
||||
isValidPath: vi.fn(() => true),
|
||||
},
|
||||
},
|
||||
kvDB: { set: setSnapshot },
|
||||
localDatabase: {
|
||||
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
||||
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
|
||||
},
|
||||
replicator: { closeReplication: vi.fn() },
|
||||
};
|
||||
const processor = new ReplicateResultProcessor({
|
||||
core,
|
||||
settings: { maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false },
|
||||
} as never);
|
||||
return { processor, processSynchroniseResult, runBoundedLocalApplicationActivity };
|
||||
}
|
||||
|
||||
describe("ReplicateResultProcessor", () => {
|
||||
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" },
|
||||
@@ -22,4 +81,67 @@ describe("ReplicateResultProcessor target-filter reprocessing", () => {
|
||||
expect(enqueueAll).toHaveBeenCalledOnce();
|
||||
expect(enqueueAll).toHaveBeenCalledWith(documents);
|
||||
});
|
||||
|
||||
it("keeps one local application activity until every replicated document has been applied", async () => {
|
||||
const applying = promiseWithResolvers<void>();
|
||||
let activityFinished = false;
|
||||
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
|
||||
processSynchroniseResult: async () => applying.promise,
|
||||
});
|
||||
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
|
||||
await task();
|
||||
activityFinished = true;
|
||||
});
|
||||
|
||||
processor.enqueueAll([note("one"), note("two")]);
|
||||
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(2));
|
||||
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(1);
|
||||
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replicated-document-application",
|
||||
});
|
||||
expect(activityFinished).toBe(false);
|
||||
|
||||
applying.resolve();
|
||||
|
||||
await vi.waitFor(() => expect(activityFinished).toBe(true));
|
||||
});
|
||||
|
||||
it("settles local application activity when the final recovery snapshot fails", async () => {
|
||||
let activityFinished = false;
|
||||
const { processor, runBoundedLocalApplicationActivity } = setup({
|
||||
setSnapshot: async () => Promise.reject(new Error("snapshot failed")),
|
||||
});
|
||||
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
|
||||
await task();
|
||||
activityFinished = true;
|
||||
});
|
||||
|
||||
processor.enqueueAll([note("one")]);
|
||||
|
||||
await vi.waitFor(() => expect(activityFinished).toBe(true));
|
||||
});
|
||||
|
||||
it("releases and reacquires local application activity around processing suspension", async () => {
|
||||
const applying = promiseWithResolvers<void>();
|
||||
let completedActivities = 0;
|
||||
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
|
||||
processSynchroniseResult: async () => applying.promise,
|
||||
});
|
||||
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
|
||||
await task();
|
||||
completedActivities++;
|
||||
});
|
||||
processor.enqueueAll([note("one")]);
|
||||
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
|
||||
|
||||
processor.suspend();
|
||||
await vi.waitFor(() => expect(completedActivities).toBe(1));
|
||||
|
||||
processor.resume();
|
||||
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
|
||||
|
||||
applying.resolve();
|
||||
await vi.waitFor(() => expect(completedActivities).toBe(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,9 +113,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
|
||||
hasFocus = true;
|
||||
isLastHidden = false;
|
||||
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
|
||||
private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
|
||||
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
|
||||
|
||||
private get boundedActivityCounts(): ReactiveSource<number>[] {
|
||||
const replicator = this.services.replicator as typeof this.services.replicator & {
|
||||
boundedLocalApplicationActivityCount: ReactiveSource<number>;
|
||||
};
|
||||
return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
|
||||
}
|
||||
|
||||
private hasBoundedActivity() {
|
||||
return this.boundedActivityCounts.some((count) => count.value > 0);
|
||||
}
|
||||
|
||||
private keepReplicationActiveInBackground() {
|
||||
return (
|
||||
this.settings.keepReplicationActiveInBackground &&
|
||||
@@ -125,9 +136,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
private async applyDeferredBoundedActivityLifecycle() {
|
||||
const count = this.services.replicator.boundedRemoteActivityCount;
|
||||
if (count.value !== 0) {
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
if (this.hasBoundedActivity()) {
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
return;
|
||||
}
|
||||
const deferredLifecycle = this.deferredBoundedLifecycle;
|
||||
@@ -149,17 +159,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
}
|
||||
|
||||
private deferLifecycleUntilBoundedRemoteActivityEnds() {
|
||||
if (this.boundedRemoteActivityEndHandler) return;
|
||||
const count = this.services.replicator.boundedRemoteActivityCount;
|
||||
const handler = (value: { readonly value: number }) => {
|
||||
if (value.value !== 0) return;
|
||||
count.offChanged(handler);
|
||||
this.boundedRemoteActivityEndHandler = undefined;
|
||||
private deferLifecycleUntilBoundedActivityEnds() {
|
||||
if (this.boundedActivityEndHandler) return;
|
||||
const counts = this.boundedActivityCounts;
|
||||
const handler = () => {
|
||||
if (this.hasBoundedActivity()) return;
|
||||
for (const count of counts) count.offChanged(handler);
|
||||
this.boundedActivityEndHandler = undefined;
|
||||
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
|
||||
};
|
||||
this.boundedRemoteActivityEndHandler = handler;
|
||||
count.onChanged(handler);
|
||||
this.boundedActivityEndHandler = handler;
|
||||
for (const count of counts) count.onChanged(handler);
|
||||
}
|
||||
|
||||
setHasFocus(hasFocus: boolean) {
|
||||
@@ -188,12 +198,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
if (
|
||||
this.settings.isConfigured &&
|
||||
this.services.appLifecycle.isReady() &&
|
||||
this.services.replicator.boundedRemoteActivityCount.value > 0
|
||||
this.hasBoundedActivity()
|
||||
) {
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
this.isLastHidden = isHidden;
|
||||
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -210,8 +220,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
|
||||
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
|
||||
const boundedActivityInProgress = this.hasBoundedActivity();
|
||||
if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
|
||||
this.isLastHidden = false;
|
||||
this.deferredBoundedLifecycle = undefined;
|
||||
return;
|
||||
@@ -228,18 +238,18 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
const keepActiveInBackground = this.keepReplicationActiveInBackground();
|
||||
|
||||
if (isHidden) {
|
||||
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
|
||||
if (boundedActivityInProgress && !keepActiveInBackground) {
|
||||
this.deferredBoundedLifecycle = "suspend-if-hidden";
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
} else if (!keepActiveInBackground) {
|
||||
await this.services.appLifecycle.onSuspending();
|
||||
}
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
|
||||
if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
|
||||
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
|
||||
this.deferLifecycleUntilBoundedRemoteActivityEnds();
|
||||
this.deferLifecycleUntilBoundedActivityEnds();
|
||||
return;
|
||||
}
|
||||
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
|
||||
|
||||
@@ -24,6 +24,7 @@ function setup(opts: SetupOptions) {
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
const boundedRemoteActivityCount = reactiveSource(0);
|
||||
const boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
const core = {
|
||||
_services: {
|
||||
@@ -38,7 +39,7 @@ function setup(opts: SetupOptions) {
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replicator: { boundedRemoteActivityCount },
|
||||
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -56,7 +57,13 @@ function setup(opts: SetupOptions) {
|
||||
// The handler reads `activeWindow.document.hidden`.
|
||||
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
|
||||
|
||||
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
|
||||
return {
|
||||
module,
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
boundedRemoteActivityCount,
|
||||
boundedLocalApplicationActivityCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
|
||||
@@ -109,6 +116,21 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("defers suspension while local document application is active", async () => {
|
||||
const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
hidden: true,
|
||||
});
|
||||
boundedLocalApplicationActivityCount.value = 1;
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
|
||||
|
||||
boundedLocalApplicationActivityCount.value = 0;
|
||||
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("defers mobile suspension while bounded remote activity is running", async () => {
|
||||
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: false },
|
||||
|
||||
@@ -74,6 +74,11 @@ export class DocumentHistoryModal extends Modal {
|
||||
currentDeleted = false;
|
||||
initialRev?: string;
|
||||
|
||||
// Revision navigation state (◀/▶ beside the range slider)
|
||||
revPrevBtn!: HTMLButtonElement;
|
||||
revNextBtn!: HTMLButtonElement;
|
||||
revNavIndicator!: HTMLSpanElement;
|
||||
|
||||
// Diff navigation state
|
||||
currentDiffIndex = -1;
|
||||
diffNavContainer!: HTMLDivElement;
|
||||
@@ -84,6 +89,8 @@ export class DocumentHistoryModal extends Modal {
|
||||
searchKeyword = "";
|
||||
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
|
||||
currentSearchIndex = -1;
|
||||
searchPrevBtn!: HTMLButtonElement;
|
||||
searchNextBtn!: HTMLButtonElement;
|
||||
searchResultIndicator!: HTMLSpanElement;
|
||||
searchProgressIndicator!: HTMLSpanElement;
|
||||
searchTimeout: number | null = null;
|
||||
@@ -125,12 +132,14 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.range.value = this.range.max;
|
||||
this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`);
|
||||
await this.loadRevs(initialRev);
|
||||
this.updateRevisionNavUI();
|
||||
} catch (ex) {
|
||||
if (isErrorOfMissingDoc(ex)) {
|
||||
this.range.max = "0";
|
||||
this.range.value = "";
|
||||
this.range.disabled = true;
|
||||
this.contentView.setText(`We don't have any history for this note.`);
|
||||
this.updateRevisionNavUI();
|
||||
} else {
|
||||
this.contentView.setText(`Error while loading file.`);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
@@ -148,6 +157,37 @@ export class DocumentHistoryModal extends Modal {
|
||||
const index = this.revs_info.length - 1 - (Number(this.range.value) || 0);
|
||||
const rev = this.revs_info[index];
|
||||
await this.showExactRev(rev.rev);
|
||||
this.updateRevisionNavUI();
|
||||
}
|
||||
|
||||
navigateVersion(direction: "older" | "newer") {
|
||||
const current = Number(this.range.value) || 0;
|
||||
const max = Number(this.range.max) || 0;
|
||||
|
||||
if (direction === "older" && current > 0) {
|
||||
this.range.value = `${current - 1}`;
|
||||
} else if (direction === "newer" && current < max) {
|
||||
this.range.value = `${current + 1}`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateRevisionNavUI();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
}
|
||||
|
||||
updateRevisionNavUI() {
|
||||
if (!this.revNavIndicator) return;
|
||||
|
||||
const total = this.revs_info.length;
|
||||
const max = Number(this.range.max) || 0;
|
||||
const current = Number(this.range.value) || 0;
|
||||
|
||||
this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014");
|
||||
|
||||
const disabled = !!this.range.disabled || total <= 1;
|
||||
this.revPrevBtn.disabled = disabled || current <= 0;
|
||||
this.revNextBtn.disabled = disabled || current >= max;
|
||||
}
|
||||
BlobURLs = new Map<string, string>();
|
||||
|
||||
@@ -391,6 +431,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (!keyword) {
|
||||
this.searchResultIndicator.setText("");
|
||||
this.searchProgressIndicator.setText("");
|
||||
this.updateSearchUI();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -464,6 +505,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
|
||||
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
|
||||
}
|
||||
|
||||
const hasResults = this.searchResults.length > 0;
|
||||
this.searchPrevBtn.disabled = !hasResults;
|
||||
this.searchNextBtn.disabled = !hasResults;
|
||||
}
|
||||
|
||||
navigateSearch(direction: "prev" | "next") {
|
||||
@@ -515,12 +560,14 @@ export class DocumentHistoryModal extends Modal {
|
||||
}, 500);
|
||||
});
|
||||
|
||||
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
|
||||
this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => {
|
||||
e.title = "Previous match";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateSearch("prev"));
|
||||
});
|
||||
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
|
||||
this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => {
|
||||
e.title = "Next match";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateSearch("next"));
|
||||
});
|
||||
|
||||
@@ -530,18 +577,37 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.searchProgressIndicator = searchRow.createSpan({ text: "" });
|
||||
this.searchProgressIndicator.addClass("history-search-progress-indicator");
|
||||
|
||||
const divView = contentEl.createDiv("");
|
||||
divView.addClass("op-flex");
|
||||
const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" });
|
||||
|
||||
divView.createEl("input", { type: "range" }, (e) => {
|
||||
this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => {
|
||||
e.addClass("history-rev-nav-btn");
|
||||
e.title = "Older revision";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateVersion("older"));
|
||||
});
|
||||
|
||||
revNavRow.createEl("input", { type: "range" }, (e) => {
|
||||
this.range = e;
|
||||
e.addEventListener("change", (e) => {
|
||||
e.addEventListener("change", () => {
|
||||
this.updateRevisionNavUI();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
e.addEventListener("input", (e) => {
|
||||
e.addEventListener("input", () => {
|
||||
this.updateRevisionNavUI();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
});
|
||||
|
||||
this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => {
|
||||
e.addClass("history-rev-nav-btn");
|
||||
e.title = "Newer revision";
|
||||
e.disabled = true;
|
||||
e.addEventListener("click", () => this.navigateVersion("newer"));
|
||||
});
|
||||
|
||||
this.revNavIndicator = revNavRow.createSpan({ text: "\u2014" }, (e) => {
|
||||
e.addClass("history-rev-indicator");
|
||||
});
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
diffOptionsRow.addClass("op-info");
|
||||
diffOptionsRow.addClass("diff-options-row");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { DocumentHistoryModal } from "@/modules/features/DocumentHistory/DocumentHistoryModal.ts";
|
||||
import { isPlainText, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let core: LiveSyncBaseCore;
|
||||
|
||||
@@ -207,29 +208,35 @@
|
||||
<div class="row"><label for="">To:</label><input type="date" bind:value={dispDateTo} disabled={loading} /></div>
|
||||
<div class="row">
|
||||
<label for="">Info:</label>
|
||||
<label><input type="checkbox" bind:checked={showDiffInfo} disabled={loading} /><span>Diff</span></label>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={showChunkCorrected} disabled={loading} /><span>Chunks</span
|
||||
><input type="checkbox" bind:checked={showDiffInfo} disabled={loading} /><span
|
||||
>{translateMessage("Diff")}</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={checkStorageDiff} disabled={loading} /><span>File integrity</span
|
||||
><input type="checkbox" bind:checked={showChunkCorrected} disabled={loading} /><span
|
||||
>{translateMessage("Chunks")}</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" bind:checked={checkStorageDiff} disabled={loading} /><span
|
||||
>{translateMessage("File integrity")}</span
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="">Gathering information...</div>
|
||||
<div class="">{translateMessage("Gathering information...")}</div>
|
||||
{/if}
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th> Date </th>
|
||||
<th> Path </th>
|
||||
<th> Rev </th>
|
||||
<th> Stat </th>
|
||||
<th> {translateMessage("Date")} </th>
|
||||
<th> {translateMessage("Path")} </th>
|
||||
<th> {translateMessage("Rev")} </th>
|
||||
<th> {translateMessage("Stat")} </th>
|
||||
{#if showChunkCorrected}
|
||||
<th> Chunks </th>
|
||||
<th> {translateMessage("Chunks")} </th>
|
||||
{/if}
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -237,7 +244,7 @@
|
||||
{#if loading}
|
||||
<div class=""></div>
|
||||
{:else}
|
||||
<div><button on:click={() => nextWeek()}>+1 week</button></div>
|
||||
<div><button on:click={() => nextWeek()}>{translateMessage("+1 week")}</button></div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -286,7 +293,7 @@
|
||||
{#if loading}
|
||||
<div class=""></div>
|
||||
{:else}
|
||||
<div><button on:click={() => prevWeek()}>+1 week</button></div>
|
||||
<div><button on:click={() => prevWeek()}>{translateMessage("+1 week")}</button></div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isInvertedRegExp, isValidRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
export let patterns = [] as CustomRegExpSource[];
|
||||
export let originals = [] as CustomRegExpSource[];
|
||||
@@ -31,7 +32,7 @@
|
||||
<!-- svelte-ignore a11y-label-has-associated-control -->
|
||||
<li>
|
||||
<label>{modified[idx]}{statusName[idx]}</label>
|
||||
<span class="chip">{isInvertedExp[idx] ? "INVERTED" : ""}</span>
|
||||
<span class="chip">{isInvertedExp[idx] ? translateMessage("INVERTED") : ""}</span>
|
||||
<input type="text" bind:value={pattern} class={modified[idx]} />
|
||||
<button class="iconbutton" on:click={() => remove(idx)}>🗑</button>
|
||||
</li>
|
||||
|
||||
@@ -479,10 +479,10 @@ export function paneRemoteConfig(
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🗑 Delete").onClick(async () => {
|
||||
item.setTitle($msg("🗑 Delete")).onClick(async () => {
|
||||
const confirmed = await this.services.UI.confirm.askYesNoDialog(
|
||||
`Delete remote configuration '${config.name}'?`,
|
||||
{ title: "Delete Remote Configuration", defaultOption: "No" }
|
||||
$msg("Delete remote configuration '${name}'?", { name: config.name }),
|
||||
{ title: $msg("Delete Remote Configuration"), defaultOption: "No" }
|
||||
);
|
||||
if (confirmed !== "yes") {
|
||||
return;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import Check from "@/modules/services/LiveSyncUI/components/Check.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
TYPE_BACKUP_DONE,
|
||||
TYPE_BACKUP_SKIPPED,
|
||||
@@ -48,90 +49,110 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Reset Synchronisation on This Device" />
|
||||
<DialogHeader title={translateMessage("Reset Synchronisation on This Device")} />
|
||||
<Guidance
|
||||
>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.</Guidance
|
||||
>{translateMessage(
|
||||
"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."
|
||||
)}</Guidance
|
||||
>
|
||||
<Guidance important title="⚠️ Important Notice">
|
||||
<Guidance important title={translateMessage("⚠️ Important Notice")}>
|
||||
<strong
|
||||
>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.</strong
|
||||
>{translateMessage(
|
||||
"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."
|
||||
)}</strong
|
||||
><br />
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</Guidance>
|
||||
<hr />
|
||||
<Instruction>
|
||||
<Question
|
||||
><strong>To minimise the creation of new conflicts</strong>, 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.</Question
|
||||
><strong>{translateMessage("To minimise the creation of new conflicts")}</strong>{translateMessage(
|
||||
", 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."
|
||||
)}</Question
|
||||
>
|
||||
<Options>
|
||||
<Option
|
||||
selectedValue={TYPE_IDENTICAL}
|
||||
title="The files in this Vault are almost identical to the server's."
|
||||
title={translateMessage("The files in this Vault are almost identical to the server's.")}
|
||||
bind:value={vaultType}
|
||||
>
|
||||
(e.g., immediately after restoring on another computer, or having recovered from a backup)
|
||||
{translateMessage(
|
||||
"(e.g., immediately after restoring on another computer, or having recovered from a backup)"
|
||||
)}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_INDEPENDENT}
|
||||
title="This Vault is empty, or contains only new files that are not on the server."
|
||||
title={translateMessage("This Vault is empty, or contains only new files that are not on the server.")}
|
||||
bind:value={vaultType}
|
||||
>
|
||||
(e.g., setting up for the first time on a new smartphone, starting from a clean slate)
|
||||
{translateMessage("(e.g., setting up for the first time on a new smartphone, starting from a clean slate)")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_UNBALANCED}
|
||||
title="There may be differences between the files in this Vault and the server."
|
||||
title={translateMessage("There may be differences between the files in this Vault and the server.")}
|
||||
bind:value={vaultType}
|
||||
>
|
||||
(e.g., after editing many files whilst offline)
|
||||
{translateMessage("(e.g., after editing many files whilst offline)")}
|
||||
<InfoNote info>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<hr />
|
||||
<Instruction>
|
||||
<Question>Have you created a backup before proceeding?</Question>
|
||||
<Question>{translateMessage("Have you created a backup before proceeding?")}</Question>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_BACKUP_DONE} title="I have created a backup of my Vault." bind:value={backupType} />
|
||||
<Option
|
||||
selectedValue={TYPE_BACKUP_DONE}
|
||||
title={translateMessage("I have created a backup of my Vault.")}
|
||||
bind:value={backupType}
|
||||
/>
|
||||
<Option
|
||||
selectedValue={TYPE_BACKUP_SKIPPED}
|
||||
title="I understand the risks and will proceed without a backup."
|
||||
title={translateMessage("I understand the risks and will proceed without a backup.")}
|
||||
bind:value={backupType}
|
||||
/>
|
||||
<Option
|
||||
selectedValue={TYPE_UNABLE_TO_BACKUP}
|
||||
title="I am unable to create a backup of my Vault."
|
||||
title={translateMessage("I am unable to create a backup of my Vault.")}
|
||||
bind:value={backupType}
|
||||
>
|
||||
<InfoNote error visible={backupType === TYPE_UNABLE_TO_BACKUP}>
|
||||
<strong
|
||||
>It is strongly advised to create a backup before proceeding. Continuing without a backup may lead
|
||||
to data loss.
|
||||
>{translateMessage(
|
||||
"It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss."
|
||||
)}
|
||||
</strong>
|
||||
<br />
|
||||
If you understand the risks and still wish to proceed, select so.
|
||||
{translateMessage("If you understand the risks and still wish to proceed, select so.")}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<Instruction>
|
||||
<ExtraItems title="Advanced">
|
||||
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<Check
|
||||
title={translateMessage("Prevent fetching configuration from server")}
|
||||
bind:value={preventFetchingConfig}
|
||||
/>
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Reset and Resume Synchronisation" important disabled={!canProceed} commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
|
||||
<Decision
|
||||
title={translateMessage("Reset and Resume Synchronisation")}
|
||||
important
|
||||
disabled={!canProceed}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCEL)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
let userType = $state<IntroResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_NEW_USER) {
|
||||
return "Yes, I want to set up a new synchronisation";
|
||||
return translateMessage("Yes, I want to set up a new synchronisation");
|
||||
} else if (userType === TYPE_EXISTING_USER) {
|
||||
return "Yes, I want to add this device to my existing synchronisation";
|
||||
return translateMessage("Yes, I want to add this device to my existing synchronisation");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
@@ -30,31 +30,41 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Welcome to Self-hosted LiveSync" />
|
||||
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance>
|
||||
<DialogHeader title={translateMessage("Welcome to Self-hosted LiveSync")} />
|
||||
<Guidance
|
||||
>{translateMessage(
|
||||
"We will now guide you through a few questions to simplify the synchronisation setup."
|
||||
)}</Guidance
|
||||
>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Instruction>
|
||||
<Question>First, please select the option that best describes your current situation.</Question>
|
||||
<Question>{translateMessage("First, please select the option that best describes your current situation.")}</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_NEW_USER} title="I am setting this up for the first time" bind:value={userType}>
|
||||
(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.
|
||||
<Option
|
||||
selectedValue={TYPE_NEW_USER}
|
||||
title={translateMessage("I am setting this up for the first time")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage(
|
||||
"(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."
|
||||
)}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_EXISTING_USER}
|
||||
title="I am adding a device to an existing synchronisation setup"
|
||||
title={translateMessage("I am adding a device to an existing synchronisation setup")}
|
||||
bind:value={userType}
|
||||
>
|
||||
(Select this if you are already using synchronisation on another computer or smartphone.) This option is
|
||||
suitable if you are new to LiveSync and want to set it up from scratch.
|
||||
{translateMessage(
|
||||
"(Select this if you are already using synchronisation on another computer or smartphone.) This option is suitable if you are new to LiveSync and want to set it up from scratch."
|
||||
)}
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
TYPE_NEW,
|
||||
TYPE_COMPATIBLE_EXISTING,
|
||||
} from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: OutroAskUserModeResultType) => void;
|
||||
@@ -25,58 +26,66 @@
|
||||
});
|
||||
const proceedMessage = $derived.by(() => {
|
||||
if (userType === TYPE_NEW) {
|
||||
return "Proceed to the next step.";
|
||||
return translateMessage("Proceed to the next step.");
|
||||
} else if (userType === TYPE_EXISTING) {
|
||||
return "Proceed to the next step.";
|
||||
return translateMessage("Proceed to the next step.");
|
||||
} else if (userType === TYPE_COMPATIBLE_EXISTING) {
|
||||
return "Apply the settings";
|
||||
return translateMessage("Apply the settings");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Mostly Complete: Decision Required" />
|
||||
<DialogHeader title={translateMessage("Mostly Complete: Decision Required")} />
|
||||
<Guidance>
|
||||
The connection to the server has been configured successfully. As the next step, <strong
|
||||
>the local database, that is to say the synchronisation information, must be reconstituted.</strong
|
||||
{translateMessage("The connection to the server has been configured successfully. As the next step,")} <strong
|
||||
>{translateMessage(
|
||||
"the local database, that is to say the synchronisation information, must be reconstituted."
|
||||
)}</strong
|
||||
>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select your situation.</Question>
|
||||
<Question>{translateMessage("Please select your situation.")}</Question>
|
||||
<Option
|
||||
title="I am setting up a new server for the first time / I want to reset my existing server."
|
||||
title={translateMessage(
|
||||
"I am setting up a new server for the first time / I want to reset my existing server."
|
||||
)}
|
||||
bind:value={userType}
|
||||
selectedValue={TYPE_NEW}
|
||||
>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
<Option
|
||||
title="My remote server is already set up. I want to join this device."
|
||||
title={translateMessage("My remote server is already set up. I want to join this device.")}
|
||||
bind:value={userType}
|
||||
selectedValue={TYPE_EXISTING}
|
||||
>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
<Option
|
||||
title="The remote is already set up, and the configuration is compatible (or got compatible by this operation)."
|
||||
title={translateMessage(
|
||||
"The remote is already set up, and the configuration is compatible (or got compatible by this operation)."
|
||||
)}
|
||||
bind:value={userType}
|
||||
selectedValue={TYPE_COMPATIBLE_EXISTING}
|
||||
>
|
||||
<InfoNote warning>
|
||||
Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is
|
||||
compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you
|
||||
are doing.
|
||||
{translateMessage(
|
||||
"Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing."
|
||||
)}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedMessage} important={true} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>PLEASE NOTE</strong>
|
||||
<strong>{translateMessage("PLEASE NOTE")}</strong>
|
||||
<br />
|
||||
{translateMessage(
|
||||
"After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data."
|
||||
@@ -43,28 +43,40 @@
|
||||
important={true}
|
||||
commit={() => setResult(TYPE_APPLY)}
|
||||
/>
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{:else}
|
||||
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
|
||||
<DialogHeader title={translateMessage("Setup Complete: Preparing to Fetch Synchronisation Data")} />
|
||||
<Guidance>
|
||||
<p>
|
||||
The connection to the server has been configured successfully. As the next step, <strong
|
||||
>the latest synchronisation data will be downloaded from the server to this device.</strong
|
||||
{translateMessage("The connection to the server has been configured successfully. As the next step,")}
|
||||
<strong
|
||||
>{translateMessage(
|
||||
"the latest synchronisation data will be downloaded from the server to this device."
|
||||
)}</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>PLEASE NOTE</strong>
|
||||
<strong>{translateMessage("PLEASE NOTE")}</strong>
|
||||
<br />
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
|
||||
<Question
|
||||
>{translateMessage(
|
||||
"Please select the button below to restart and proceed to the data fetching confirmation."
|
||||
)}</Question
|
||||
>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision
|
||||
title={translateMessage("Restart and Fetch Data")}
|
||||
important={true}
|
||||
commit={() => setResult(TYPE_APPLY)}
|
||||
/>
|
||||
<Decision title={translateMessage("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -36,28 +36,35 @@
|
||||
important={true}
|
||||
commit={() => setResult(TYPE_APPLY)}
|
||||
/>
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={msg("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{:else}
|
||||
<DialogHeader title="Setup Complete: Preparing to Initialise Server" />
|
||||
<DialogHeader title={msg("Setup Complete: Preparing to Initialise Server")} />
|
||||
<Guidance>
|
||||
<p>
|
||||
The connection to the server has been configured successfully. As the next step, <strong
|
||||
>the synchronisation data on the server will be built based on the current data on this device.</strong
|
||||
{msg("The connection to the server has been configured successfully. As the next step,")} <strong
|
||||
>{msg(
|
||||
"the synchronisation data on the server will be built based on the current data on this device."
|
||||
)}</strong
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>IMPORTANT</strong>
|
||||
<strong>{msg("IMPORTANT")}</strong>
|
||||
<br />
|
||||
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.
|
||||
{msg(
|
||||
"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."
|
||||
)}
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select the button below to restart and proceed to the final confirmation.</Question>
|
||||
<Question>{msg("Please select the button below to restart and proceed to the final confirmation.")}</Question>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Restart and Initialise Server" important={true} commit={() => setResult(TYPE_APPLY)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision
|
||||
title={msg("Restart and Initialise Server")}
|
||||
important={true}
|
||||
commit={() => setResult(TYPE_APPLY)}
|
||||
/>
|
||||
<Decision title={msg("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
detectedIssues = fixResults;
|
||||
} catch (e) {
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-check");
|
||||
detectedIssues.push({ message: `Error during testAndFixSettings: ${e}`, result: "error", classes: [] });
|
||||
detectedIssues.push({
|
||||
message: translateMessage("Error during testAndFixSettings: ${reason}", { reason: `${e}` }),
|
||||
result: "error",
|
||||
classes: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
function isErrorResult(result: ConfigCheckResult): result is ResultError<unknown> | ResultErrorMessage {
|
||||
@@ -71,7 +75,9 @@
|
||||
</div>
|
||||
{#if isFixableError(issue)}
|
||||
<div class="operations">
|
||||
<button onclick={() => fixIssue(issue)} class="mod-cta" disabled={processing}>Fix</button>
|
||||
<button onclick={() => fixIssue(issue)} class="mod-cta" disabled={processing}
|
||||
>{translateMessage("Fix")}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -83,15 +89,15 @@
|
||||
<details open={!isAllSuccess}>
|
||||
<summary>
|
||||
{#if detectedIssues.length === 0}
|
||||
No checks have been performed yet.
|
||||
{translateMessage("No checks have been performed yet.")}
|
||||
{:else if isAllSuccess}
|
||||
All checks passed successfully!
|
||||
{translateMessage("All checks passed successfully!")}
|
||||
{:else}
|
||||
{errorIssueCount} issue(s) detected!
|
||||
{translateMessage("${count} issue(s) detected!", { count: `${errorIssueCount}` })}
|
||||
{/if}
|
||||
</summary>
|
||||
{#if detectedIssues.length > 0}
|
||||
<h3>Issue detection log:</h3>
|
||||
<h3>{translateMessage("Issue detection log:")}</h3>
|
||||
{#each detectedIssues as issue}
|
||||
{@render result(issue)}
|
||||
{/each}
|
||||
|
||||
@@ -58,57 +58,69 @@
|
||||
</Check>
|
||||
</Guidance>
|
||||
{:else}
|
||||
<DialogHeader title="Final Confirmation: Overwrite Server Data with This Device's Files" />
|
||||
<DialogHeader title={msg("Final Confirmation: Overwrite Server Data with This Device's Files")} />
|
||||
<Guidance
|
||||
>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 <strong>the single, authoritative master copy</strong>.</Guidance
|
||||
>{msg(
|
||||
"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"
|
||||
)} <strong>{msg("the single, authoritative master copy")}</strong>.</Guidance
|
||||
>
|
||||
<InfoNote>
|
||||
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.
|
||||
{msg(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Guidance important title="⚠️ Please Confirm the Following">
|
||||
<Guidance important title={msg("⚠️ Please Confirm the Following")}>
|
||||
<Check
|
||||
title="I understand that all changes made on other smartphones or computers possibly could be lost."
|
||||
title={msg("I understand that all changes made on other smartphones or computers possibly could be lost.")}
|
||||
bind:value={confirmationCheck1}
|
||||
>
|
||||
<InfoNote>There is a way to resolve this on other devices.</InfoNote>
|
||||
<InfoNote>Of course, we can back up the data before proceeding.</InfoNote>
|
||||
<InfoNote>{msg("There is a way to resolve this on other devices.")}</InfoNote>
|
||||
<InfoNote>{msg("Of course, we can back up the data before proceeding.")}</InfoNote>
|
||||
</Check>
|
||||
<Check
|
||||
title="I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
|
||||
title={msg(
|
||||
"I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
|
||||
)}
|
||||
bind:value={confirmationCheck2}
|
||||
>
|
||||
<InfoNote>by resetting the remote, you will be informed on other devices.</InfoNote>
|
||||
<InfoNote>{msg("by resetting the remote, you will be informed on other devices.")}</InfoNote>
|
||||
</Check>
|
||||
<Check title="I understand that this action is irreversible once performed." bind:value={confirmationCheck3} />
|
||||
<Check
|
||||
title={msg("I understand that this action is irreversible once performed.")}
|
||||
bind:value={confirmationCheck3}
|
||||
/>
|
||||
</Guidance>
|
||||
{/if}
|
||||
<hr />
|
||||
<Instruction>
|
||||
<Question>Have you created a backup before proceeding?</Question>
|
||||
<Question>{msg("Have you created a backup before proceeding?")}</Question>
|
||||
<InfoNote warning>
|
||||
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe
|
||||
location.
|
||||
{msg(
|
||||
"This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe location."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_BACKUP_DONE} title="I have created a backup of my Vault." bind:value={backupType} />
|
||||
<Option
|
||||
selectedValue={TYPE_BACKUP_DONE}
|
||||
title={msg("I have created a backup of my Vault.")}
|
||||
bind:value={backupType}
|
||||
/>
|
||||
<Option
|
||||
selectedValue={TYPE_BACKUP_SKIPPED}
|
||||
title="I understand the risks and will proceed without a backup."
|
||||
title={msg("I understand the risks and will proceed without a backup.")}
|
||||
bind:value={backupType}
|
||||
/>
|
||||
<Option
|
||||
selectedValue={TYPE_UNABLE_TO_BACKUP}
|
||||
title="I am unable to create a backup of my Vaults."
|
||||
title={msg("I am unable to create a backup of my Vaults.")}
|
||||
bind:value={backupType}
|
||||
>
|
||||
<InfoNote error visible={backupType === TYPE_UNABLE_TO_BACKUP}>
|
||||
<strong
|
||||
>You should create a new synchronisation destination and rebuild your data there. <br /> After that,
|
||||
synchronise to a brand new vault on each other device with the new remote one by one.</strong
|
||||
>{msg("You should create a new synchronisation destination and rebuild your data there.")} <br />
|
||||
{msg(
|
||||
"After that, synchronise to a brand new vault on each other device with the new remote one by one."
|
||||
)}</strong
|
||||
>
|
||||
</InfoNote>
|
||||
</Option>
|
||||
@@ -116,17 +128,19 @@
|
||||
</Instruction>
|
||||
{#if !isP2P}
|
||||
<Instruction>
|
||||
<ExtraItems title="Advanced">
|
||||
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
|
||||
<ExtraItems title={msg("Advanced")}>
|
||||
<Check title={msg("Prevent fetching configuration from server")} bind:value={preventFetchingConfig} />
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
{/if}
|
||||
<UserDecisions>
|
||||
<Decision
|
||||
title={isP2P ? msg("Ui.SetupWizard.RebuildEverythingP2P.Proceed") : "I Understand, Overwrite Server"}
|
||||
title={isP2P
|
||||
? msg("Ui.SetupWizard.RebuildEverythingP2P.Proceed")
|
||||
: msg("I Understand, Overwrite Server")}
|
||||
important
|
||||
disabled={!canProceed}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
|
||||
<Decision title={msg("Cancel")} commit={() => setResult(TYPE_CANCEL)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { TYPE_CLOSE, type ScanQRCodeResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = {
|
||||
setResult: (_result: ScanQRCodeResultType) => void;
|
||||
@@ -12,17 +13,25 @@
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Scan QR Code" />
|
||||
<Guidance>Please follow the steps below to import settings from your existing device.</Guidance>
|
||||
<DialogHeader title={translateMessage("Scan QR Code")} />
|
||||
<Guidance>{translateMessage("Please follow the steps below to import settings from your existing device.")}</Guidance>
|
||||
<Instruction>
|
||||
<!-- <Question>How would you like to configure the connection to your server?</Question> -->
|
||||
<ol>
|
||||
<li>On this device, please keep this Vault open.</li>
|
||||
<li>On the source device, open Obsidian.</li>
|
||||
<li>On the source device, from the command palette, run the 'Show settings as a QR code' command.</li>
|
||||
<li>On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.</li>
|
||||
<li>{translateMessage("On this device, please keep this Vault open.")}</li>
|
||||
<li>{translateMessage("On the source device, open Obsidian.")}</li>
|
||||
<li>
|
||||
{translateMessage(
|
||||
"On the source device, from the command palette, run the 'Show settings as a QR code' command."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{translateMessage(
|
||||
"On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code."
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title="Close this dialog" important={true} commit={() => setResult(TYPE_CLOSE)} />
|
||||
<Decision title={translateMessage("Close this dialog")} important={true} commit={() => setResult(TYPE_CLOSE)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
let userType = $state<SelectMethodExistingResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
return translateMessage("Proceed with Setup URI");
|
||||
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
|
||||
return translateMessage("Ui.SetupWizard.SelectExisting.ProceedManual");
|
||||
} else if (userType === TYPE_SCAN_QR_CODE) {
|
||||
return "Scan the QR code displayed on an active device using this device's camera.";
|
||||
return translateMessage("Scan the QR code displayed on an active device using this device's camera.");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
@@ -37,16 +37,24 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Device Setup Method" />
|
||||
<Guidance>You are adding this device to an existing synchronisation setup.</Guidance>
|
||||
<DialogHeader title={translateMessage("Device Setup Method")} />
|
||||
<Guidance>{translateMessage("You are adding this device to an existing synchronisation setup.")}</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select a method to import the settings from another device.</Question>
|
||||
<Question>{translateMessage("Please select a method to import the settings from another device.")}</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
|
||||
Paste the Setup URI generated from one of your active devices.
|
||||
<Option
|
||||
selectedValue={TYPE_USE_SETUP_URI}
|
||||
title={translateMessage("Use a Setup URI (Recommended)")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage("Paste the Setup URI generated from one of your active devices.")}
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_SCAN_QR_CODE} title="Scan a QR Code (Recommended for mobile)" bind:value={userType}>
|
||||
Scan the QR code displayed on an active device using this device's camera.
|
||||
<Option
|
||||
selectedValue={TYPE_SCAN_QR_CODE}
|
||||
title={translateMessage("Scan a QR Code (Recommended for mobile)")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage("Scan the QR code displayed on an active device using this device's camera.")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_CONFIGURE_MANUALLY}
|
||||
@@ -59,5 +67,5 @@
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
let userType = $state<SelectMethodNewUserResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
return translateMessage("Proceed with Setup URI");
|
||||
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
|
||||
return translateMessage("Ui.SetupWizard.SelectNew.ProceedManual");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
@@ -34,12 +34,16 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Connection Method" />
|
||||
<DialogHeader title={translateMessage("Connection Method")} />
|
||||
<Guidance>{translateMessage("Ui.SetupWizard.SelectNew.Guidance")}</Guidance>
|
||||
<Instruction>
|
||||
<Question>{translateMessage("Ui.SetupWizard.SelectNew.Question")}</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
|
||||
<Option
|
||||
selectedValue={TYPE_USE_SETUP_URI}
|
||||
title={translateMessage("Use a Setup URI (Recommended)")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage("Ui.SetupWizard.SelectNew.SetupUriOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
@@ -56,5 +60,5 @@
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_COUCHDB) {
|
||||
return "Continue to CouchDB setup";
|
||||
return translateMessage("Continue to CouchDB setup");
|
||||
} else if (userType === TYPE_BUCKET) {
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
|
||||
} else if (userType === TYPE_P2P) {
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
@@ -41,8 +41,7 @@
|
||||
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
|
||||
This is the most suitable synchronisation method for the design. All functions are available. You must have
|
||||
set up a CouchDB instance.
|
||||
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_BUCKET}
|
||||
@@ -64,5 +63,5 @@
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("No, please take me back")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { copyTo, pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -82,17 +83,17 @@
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return "Failed to create replicator instance.";
|
||||
return translateMessage("Failed to create replicator instance.");
|
||||
}
|
||||
try {
|
||||
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
|
||||
if (result) {
|
||||
return "";
|
||||
} else {
|
||||
return "Failed to connect to the server. Please check your settings.";
|
||||
return translateMessage("Failed to connect to the server. Please check your settings.");
|
||||
}
|
||||
} catch (e) {
|
||||
return `Failed to connect to the server: ${e}`;
|
||||
return translateMessage("Failed to connect to the server: ${reason}", { reason: `${e}` });
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
@@ -109,7 +110,7 @@
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
error = translateMessage("Error during connection test: ${reason}", { reason: `${e}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -122,9 +123,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="S3/MinIO/R2 Configuration" />
|
||||
<Guidance>Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.</Guidance>
|
||||
<InputRow label="Endpoint URL">
|
||||
<DialogHeader title={translateMessage("S3/MinIO/R2 Configuration")} />
|
||||
<Guidance
|
||||
>{translateMessage(
|
||||
"Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service."
|
||||
)}</Guidance
|
||||
>
|
||||
<InputRow label={translateMessage("Endpoint URL")}>
|
||||
<input
|
||||
type="text"
|
||||
name="s3-endpoint"
|
||||
@@ -137,13 +142,15 @@
|
||||
bind:value={syncSetting.endpoint}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isEndpointInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
|
||||
<InfoNote warning visible={isEndpointInsecure}
|
||||
>{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}</InfoNote
|
||||
>
|
||||
|
||||
<InputRow label="Access Key ID">
|
||||
<InputRow label={translateMessage("Access Key ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="s3-access-key-id"
|
||||
placeholder="Enter your Access Key ID"
|
||||
placeholder={translateMessage("Enter your Access Key ID")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@@ -152,19 +159,19 @@
|
||||
/>
|
||||
</InputRow>
|
||||
|
||||
<InputRow label="Secret Access Key">
|
||||
<InputRow label={translateMessage("Secret Access Key")}>
|
||||
<Password
|
||||
name="s3-secret-access-key"
|
||||
placeholder="Enter your Secret Access Key"
|
||||
placeholder={translateMessage("Enter your Secret Access Key")}
|
||||
required
|
||||
bind:value={syncSetting.secretKey}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Bucket Name">
|
||||
<InputRow label={translateMessage("Bucket Name")}>
|
||||
<input
|
||||
type="text"
|
||||
name="s3-bucket-name"
|
||||
placeholder="Enter your Bucket Name"
|
||||
placeholder={translateMessage("Enter your Bucket Name")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@@ -172,26 +179,26 @@
|
||||
bind:value={syncSetting.bucket}
|
||||
/></InputRow
|
||||
>
|
||||
<InputRow label="Region">
|
||||
<InputRow label={translateMessage("Region")}>
|
||||
<input
|
||||
type="text"
|
||||
name="s3-region"
|
||||
placeholder="Enter your Region (e.g., us-east-1, auto for R2)"
|
||||
placeholder={translateMessage("Enter your Region (e.g., us-east-1, auto for R2)")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.region}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Use Path-Style Access">
|
||||
<InputRow label={translateMessage("Use Path-Style Access")}>
|
||||
<input type="checkbox" name="s3-use-path-style" bind:checked={syncSetting.forcePathStyle} />
|
||||
</InputRow>
|
||||
|
||||
<InputRow label="Folder Prefix">
|
||||
<InputRow label={translateMessage("Folder Prefix")}>
|
||||
<input
|
||||
type="text"
|
||||
name="s3-folder-prefix"
|
||||
placeholder="Enter a folder prefix (optional)"
|
||||
placeholder={translateMessage("Enter a folder prefix (optional)")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@@ -199,20 +206,21 @@
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label="Use internal API">
|
||||
<InputRow label={translateMessage("Use internal API")}>
|
||||
<input type="checkbox" name="s3-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title="Advanced Settings">
|
||||
<InputRow label="Custom Headers">
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InputRow label={translateMessage("Custom Headers")}>
|
||||
<textarea
|
||||
name="bucket-custom-headers"
|
||||
placeholder="e.g., x-example-header: value\n another-header: value2"
|
||||
@@ -229,11 +237,16 @@
|
||||
</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
<Decision title="Continue anyway" commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
<Decision
|
||||
title={translateMessage("Test Settings and Continue")}
|
||||
important
|
||||
disabled={!canProceed}
|
||||
commit={() => checkAndCommit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
const trialRemoteSetting = generateSetting();
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
return "Failed to create replicator instance.";
|
||||
return translateMessage("Failed to create replicator instance.");
|
||||
}
|
||||
try {
|
||||
const result = await probeCouchDBConnection(
|
||||
@@ -86,10 +86,12 @@
|
||||
if (result.ok) {
|
||||
return "";
|
||||
} else {
|
||||
return `Failed to connect to the server: ${result.reason}`;
|
||||
return translateMessage("Failed to connect to the server: ${reason}", {
|
||||
reason: `${result.reason}`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return `Failed to connect to the server: ${e}`;
|
||||
return translateMessage("Failed to connect to the server: ${reason}", { reason: `${e}` });
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
@@ -106,7 +108,7 @@
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
error = translateMessage("Error during connection test: ${reason}", { reason: `${e}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -159,9 +161,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="CouchDB Configuration" />
|
||||
<Guidance>Please enter the CouchDB server information below.</Guidance>
|
||||
<InputRow label="URL">
|
||||
<DialogHeader title={translateMessage("CouchDB Configuration")} />
|
||||
<Guidance>{translateMessage("Please enter the CouchDB server information below.")}</Guidance>
|
||||
<InputRow label={translateMessage("URL")}>
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-url"
|
||||
@@ -174,13 +176,15 @@
|
||||
pattern="^https?://.+"
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
|
||||
<InfoNote warning visible={isURIInsecure}
|
||||
>{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}</InfoNote
|
||||
>
|
||||
<InfoNote warning visible={isURLInvalid}>{translateMessage("Enter a complete HTTP or HTTPS URL.")}</InfoNote>
|
||||
<InputRow label="Username">
|
||||
<InputRow label={translateMessage("Username")}>
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-username"
|
||||
placeholder="Enter your username"
|
||||
placeholder={translateMessage("Enter your username")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@@ -188,20 +192,20 @@
|
||||
bind:value={syncSetting.couchDB_USER}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Password">
|
||||
<InputRow label={translateMessage("Password")}>
|
||||
<Password
|
||||
name="couchdb-password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={translateMessage("Enter your password")}
|
||||
bind:value={syncSetting.couchDB_PASSWORD}
|
||||
required
|
||||
/>
|
||||
</InputRow>
|
||||
|
||||
<InputRow label="Database Name">
|
||||
<InputRow label={translateMessage("Database Name")}>
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-database"
|
||||
placeholder="Enter your database name"
|
||||
placeholder={translateMessage("Enter your database name")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@@ -212,17 +216,17 @@
|
||||
<InfoNote>
|
||||
{translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")}
|
||||
</InfoNote>
|
||||
<InputRow label="Use Internal API">
|
||||
<InputRow label={translateMessage("Use Internal API")}>
|
||||
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title="Advanced Settings">
|
||||
<InputRow label="Custom Headers">
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InputRow label={translateMessage("Custom Headers")}>
|
||||
<textarea
|
||||
name="couchdb-custom-headers"
|
||||
placeholder="e.g., x-example-header: value\n another-header: value2"
|
||||
@@ -233,11 +237,11 @@
|
||||
></textarea>
|
||||
</InputRow>
|
||||
</ExtraItems>
|
||||
<ExtraItems title="Experimental Settings">
|
||||
<InputRow label="Use JWT Authentication">
|
||||
<ExtraItems title={translateMessage("Experimental Settings")}>
|
||||
<InputRow label={translateMessage("Use JWT Authentication")}>
|
||||
<input type="checkbox" name="couchdb-use-jwt" bind:checked={syncSetting.useJWT} />
|
||||
</InputRow>
|
||||
<InputRow label="JWT Algorithm">
|
||||
<InputRow label={translateMessage("JWT Algorithm")}>
|
||||
<select bind:value={syncSetting.jwtAlgorithm} disabled={!isUseJWT}>
|
||||
<option value="HS256">HS256</option>
|
||||
<option value="HS512">HS512</option>
|
||||
@@ -245,7 +249,7 @@
|
||||
<option value="ES512">ES512</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Expiration Duration (minutes)">
|
||||
<InputRow label={translateMessage("JWT Expiration Duration (minutes)")}>
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-exp-duration"
|
||||
@@ -254,43 +258,44 @@
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Key">
|
||||
<InputRow label={translateMessage("JWT Key")}>
|
||||
<textarea
|
||||
name="couchdb-jwt-key"
|
||||
rows="5"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
placeholder="Enter your JWT secret or private key"
|
||||
placeholder={translateMessage("Enter your JWT secret or private key")}
|
||||
bind:value={syncSetting.jwtKey}
|
||||
disabled={!isUseJWT}
|
||||
></textarea>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8
|
||||
PEM-formatted private key.
|
||||
{translateMessage(
|
||||
"For HS256/HS512 algorithms, provide the shared secret key. For ES256/ES512 algorithms, provide the pkcs8 PEM-formatted private key."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label="JWT Key ID (kid)">
|
||||
<InputRow label={translateMessage("JWT Key ID (kid)")}>
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-kid"
|
||||
placeholder="Enter your JWT Key ID"
|
||||
placeholder={translateMessage("Enter your JWT Key ID")}
|
||||
bind:value={syncSetting.jwtKid}
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="JWT Subject (sub)">
|
||||
<InputRow label={translateMessage("JWT Subject (sub)")}>
|
||||
<input
|
||||
type="text"
|
||||
name="couchdb-jwt-sub"
|
||||
placeholder="Enter your JWT Subject (CouchDB Username)"
|
||||
placeholder={translateMessage("Enter your JWT Subject (CouchDB Username)")}
|
||||
bind:value={syncSetting.jwtSub}
|
||||
disabled={!isUseJWT}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning>
|
||||
JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens.
|
||||
Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the
|
||||
server's configuration. Incidentally, I have not verified it very thoroughly.
|
||||
{translateMessage(
|
||||
"JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly."
|
||||
)}
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
@@ -307,7 +312,7 @@
|
||||
</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
@@ -323,6 +328,6 @@
|
||||
commit={() => commit()}
|
||||
/>
|
||||
{/if}
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
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 { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
@@ -47,30 +48,31 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="End-to-End Encryption" />
|
||||
<Guidance>Please configure your end-to-end encryption settings.</Guidance>
|
||||
<InputRow label="End-to-End Encryption">
|
||||
<DialogHeader title={translateMessage("End-to-End Encryption")} />
|
||||
<Guidance>{translateMessage("Please configure your end-to-end encryption settings.")}</Guidance>
|
||||
<InputRow label={translateMessage("End-to-End Encryption")}>
|
||||
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
|
||||
<Password
|
||||
name="e2ee-passphrase"
|
||||
placeholder="Enter your passphrase"
|
||||
placeholder={translateMessage("Enter your passphrase")}
|
||||
bind:value={encryptionSettings.passphrase}
|
||||
disabled={!encryptionSettings.encrypt}
|
||||
required={encryptionSettings.encrypt}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote title="Strongly Recommended">
|
||||
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.
|
||||
<InfoNote title={translateMessage("Strongly Recommended")}>
|
||||
{translateMessage(
|
||||
"Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote server. This means that even if someone gains access to the server, they won't be able to read your data without the passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices."
|
||||
)}
|
||||
<br />
|
||||
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.
|
||||
{translateMessage(
|
||||
"Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
This setting must be the same even when connecting to multiple synchronisation destinations.
|
||||
{translateMessage("This setting must be the same even when connecting to multiple synchronisation destinations.")}
|
||||
</InfoNote>
|
||||
<InputRow label="Obfuscate Properties">
|
||||
<InputRow label={translateMessage("Obfuscate Properties")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={encryptionSettings.usePathObfuscation}
|
||||
@@ -79,14 +81,13 @@
|
||||
</InputRow>
|
||||
|
||||
<InfoNote>
|
||||
Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of
|
||||
security by making it harder to identify the structure and names of your files and folders on the remote server.
|
||||
This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your
|
||||
data.
|
||||
{translateMessage(
|
||||
"Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title="Advanced">
|
||||
<InputRow label="Encryption Algorithm">
|
||||
<ExtraItems title={translateMessage("Advanced")}>
|
||||
<InputRow label={translateMessage("Encryption Algorithm")}>
|
||||
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
|
||||
{#each Object.values(E2EEAlgorithms) as alg}
|
||||
<option value={alg}>{E2EEAlgorithmNames[alg] ?? alg}</option>
|
||||
@@ -94,30 +95,33 @@
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
In most cases, you should stick with the default algorithm ({E2EEAlgorithmNames[
|
||||
DEFAULT_SETTINGS.E2EEAlgorithm
|
||||
]}), This setting is only required if you have an existing Vault encrypted in a different format.
|
||||
{translateMessage(
|
||||
"In most cases, you should stick with the default algorithm (${algorithm}), This setting is only required if you have an existing Vault encrypted in a different format.",
|
||||
{ algorithm: E2EEAlgorithmNames[DEFAULT_SETTINGS.E2EEAlgorithm] }
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
Changing the encryption algorithm will prevent access to any data previously encrypted with a different
|
||||
algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your
|
||||
data.
|
||||
{translateMessage(
|
||||
"Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data."
|
||||
)}
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote warning>
|
||||
<p>
|
||||
Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process
|
||||
actually commences. This is a security measure designed to protect your data.
|
||||
{translateMessage(
|
||||
"Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
Therefore, we ask that you exercise extreme caution when configuring server information manually. If an
|
||||
incorrect passphrase is entered, the data on the server will become corrupted. <br /><br />
|
||||
Please understand that this is intended behaviour.
|
||||
{translateMessage(
|
||||
"Therefore, we ask that you exercise extreme caution when configuring server information manually. If an incorrect passphrase is entered, the data on the server will become corrupted."
|
||||
)} <br /><br />
|
||||
{translateMessage("Please understand that this is intended behaviour.")}
|
||||
</p>
|
||||
</InfoNote>
|
||||
|
||||
<UserDecisions>
|
||||
<Decision title="Proceed" important disabled={!e2eeValid} commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("Proceed")} important disabled={!e2eeValid} commit={() => commit()} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -123,7 +123,9 @@
|
||||
try {
|
||||
const result = await probeP2PSetupConnection(replicator);
|
||||
if (!result.ok) {
|
||||
return `Failed to connect to the signalling relay: ${result.reason}`;
|
||||
return translateMessage("Failed to connect to the signalling relay: ${reason}", {
|
||||
reason: `${result.reason}`,
|
||||
});
|
||||
}
|
||||
return "";
|
||||
} finally {
|
||||
@@ -157,7 +159,7 @@
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
error = `Error during connection test: ${e}`;
|
||||
error = translateMessage("Error during connection test: ${reason}", { reason: `${e}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -178,9 +180,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="P2P Configuration" />
|
||||
<Guidance>Please enter the Peer-to-Peer Synchronisation information below.</Guidance>
|
||||
<InputRow label="Enabled">
|
||||
<DialogHeader title={translateMessage("P2P Configuration")} />
|
||||
<Guidance>{translateMessage("Please enter the Peer-to-Peer Synchronisation information below.")}</Guidance>
|
||||
<InputRow label={translateMessage("Enabled")}>
|
||||
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Signalling relay URLs")}>
|
||||
@@ -208,7 +210,7 @@
|
||||
rel="noopener noreferrer">{translateMessage("Learn more about P2P connections")}</a
|
||||
>.
|
||||
</InfoNote>
|
||||
<InputRow label="Group ID">
|
||||
<InputRow label={translateMessage("Group ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-room-id"
|
||||
@@ -218,17 +220,24 @@
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_roomID}
|
||||
/>
|
||||
<button class="button" onclick={() => generateDefaultGroupId()}>Generate Random ID</button>
|
||||
<button class="button" onclick={() => generateDefaultGroupId()}>{translateMessage("Generate Random ID")}</button>
|
||||
</InputRow>
|
||||
<InputRow label="Passphrase">
|
||||
<Password name="p2p-password" placeholder="Enter your passphrase" bind:value={syncSetting.P2P_passphrase} />
|
||||
<InputRow label={translateMessage("Passphrase")}>
|
||||
<Password
|
||||
name="p2p-password"
|
||||
placeholder={translateMessage("Enter your passphrase")}
|
||||
bind:value={syncSetting.P2P_passphrase}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
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.<br />
|
||||
Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}<br />
|
||||
{translateMessage(
|
||||
"Note that the Group ID is not limited to the generated format; you can use any string as the Group ID."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label="Device Peer ID">
|
||||
<InputRow label={translateMessage("Device Peer ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-device-peer-id"
|
||||
@@ -239,12 +248,13 @@
|
||||
bind:value={syncSetting.P2P_DevicePeerName}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="Auto Start P2P Connection">
|
||||
<InputRow label={translateMessage("Auto Start P2P Connection")}>
|
||||
<input type="checkbox" name="p2p-auto-start" bind:checked={syncSetting.P2P_AutoStart} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
|
||||
launches.
|
||||
{translateMessage(
|
||||
'If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in launches.'
|
||||
)}
|
||||
</InfoNote>
|
||||
<InputRow label={translateMessage("Announce changes automatically after connecting")}>
|
||||
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
|
||||
@@ -254,10 +264,11 @@
|
||||
"When enabled, this device notifies connected peers after a local change. The notification contains no Vault data; a peer which follows this device then fetches the change through the encrypted P2P connection."
|
||||
)}
|
||||
</InfoNote>
|
||||
<ExtraItems title="Advanced Settings">
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InfoNote>
|
||||
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.
|
||||
{translateMessage(
|
||||
"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."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
@@ -269,7 +280,7 @@
|
||||
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
|
||||
>.
|
||||
</InfoNote>
|
||||
<InputRow label="TURN Server URLs (comma-separated)">
|
||||
<InputRow label={translateMessage("TURN Server URLs (comma-separated)")}>
|
||||
<textarea
|
||||
name="p2p-turn-servers"
|
||||
placeholder="turn:turn.example.com:3478,turn:turn.example.com:443"
|
||||
@@ -279,21 +290,21 @@
|
||||
rows="5"
|
||||
></textarea>
|
||||
</InputRow>
|
||||
<InputRow label="TURN Username">
|
||||
<InputRow label={translateMessage("TURN Username")}>
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-turn-username"
|
||||
placeholder="Enter TURN username"
|
||||
placeholder={translateMessage("Enter TURN username")}
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_turnUsername}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label="TURN Credential">
|
||||
<InputRow label={translateMessage("TURN Credential")}>
|
||||
<Password
|
||||
name="p2p-turn-credential"
|
||||
placeholder="Enter TURN credential"
|
||||
placeholder={translateMessage("Enter TURN credential")}
|
||||
bind:value={syncSetting.P2P_turnCredential}
|
||||
/>
|
||||
</InputRow>
|
||||
@@ -302,11 +313,16 @@
|
||||
{error}
|
||||
</InfoNote>
|
||||
{#if processing}
|
||||
Checking connection... Please wait.
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
<Decision title="Continue anyway" commit={() => commit()} />
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
<Decision
|
||||
title={translateMessage("Test Settings and Continue")}
|
||||
important
|
||||
disabled={!canProceed}
|
||||
commit={() => checkAndCommit()}
|
||||
/>
|
||||
<Decision title={translateMessage("Continue anyway")} commit={() => commit()} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { TYPE_CANCELLED, type UseSetupURIResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = GuestDialogProps<UseSetupURIResultType, string>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
@@ -34,7 +35,7 @@
|
||||
error = "";
|
||||
if (!seemsValid) return;
|
||||
if (!passphrase) {
|
||||
error = "Passphrase is required.";
|
||||
error = translateMessage("Passphrase is required.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -47,7 +48,7 @@
|
||||
// Logger("Settings imported successfully", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
} catch (e) {
|
||||
error = "Failed to parse Setup-URI.";
|
||||
error = translateMessage("Failed to parse Setup-URI.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -56,14 +57,17 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Enter Setup URI" />
|
||||
<DialogHeader title={translateMessage("Enter Setup URI")} />
|
||||
<Guidance
|
||||
>Please enter the Setup URI that was generated during server installation or on another device, along with the vault
|
||||
passphrase.<br />
|
||||
Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.</Guidance
|
||||
>{translateMessage(
|
||||
"Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase."
|
||||
)}<br />
|
||||
{translateMessage(
|
||||
'Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.'
|
||||
)}</Guidance
|
||||
>
|
||||
|
||||
<InputRow label="Setup-URI">
|
||||
<InputRow label={translateMessage("Setup-URI")}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="obsidian://setuplivesync?settings=...."
|
||||
@@ -74,12 +78,12 @@
|
||||
required
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote visible={seemsValid}>The Setup-URI is valid and ready to use.</InfoNote>
|
||||
<InfoNote visible={seemsValid}>{translateMessage("The Setup-URI is valid and ready to use.")}</InfoNote>
|
||||
<InfoNote warning visible={!seemsValid && setupURI.trim() != ""}>
|
||||
The Setup-URI does not appear to be valid. Please check that you have copied it correctly.
|
||||
{translateMessage("The Setup-URI does not appear to be valid. Please check that you have copied it correctly.")}
|
||||
</InfoNote>
|
||||
<InputRow label="Passphrase">
|
||||
<Password placeholder="Enter your passphrase" bind:value={passphrase} required />
|
||||
<InputRow label={translateMessage("Passphrase")}>
|
||||
<Password placeholder={translateMessage("Enter your passphrase")} bind:value={passphrase} required />
|
||||
</InputRow>
|
||||
<InfoNote error visible={error.trim() != ""}>
|
||||
{error}
|
||||
@@ -87,10 +91,10 @@
|
||||
|
||||
<UserDecisions>
|
||||
<Decision
|
||||
title="Test Settings and Continue"
|
||||
title={translateMessage("Test Settings and Continue")}
|
||||
important={true}
|
||||
disabled={!canProceed}
|
||||
commit={() => processSetupURI()}
|
||||
/>
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user