mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 22:07:07 +00:00
chore: merge upstream main into history revision branch
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "@/deps.ts";
|
||||
import { getPathFromTFile, isValidPath } from "@/common/utils.ts";
|
||||
import { decodeBinary, readString } from "@lib/string_and_binary/convert.ts";
|
||||
import { decodeBinary, readString } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
|
||||
import ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import {
|
||||
type DocumentID,
|
||||
@@ -9,14 +9,20 @@ import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "@lib/common/types.ts";
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
import { isErrorOfMissingDoc } from "@lib/pouchdb/utils_couchdb.ts";
|
||||
import { fireAndForget, getDocData, readContent } from "@lib/common/utils.ts";
|
||||
import { isPlainText, stripPrefix } from "@lib/string_and_binary/path.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb";
|
||||
import { fireAndForget, getDocData, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import {
|
||||
DOCUMENT_HISTORY_PREFERENCE_KEYS,
|
||||
loadDocumentHistoryPreference,
|
||||
saveDocumentHistoryPreference,
|
||||
} from "./documentHistoryPreferences.ts";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
|
||||
function isImage(path: string) {
|
||||
const ext = path.split(".").splice(-1)[0].toLowerCase();
|
||||
@@ -106,12 +112,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (!file && id) {
|
||||
this.file = this.services.path.id2path(id);
|
||||
}
|
||||
// eslint-disable-next-line obsidianmd/no-unsupported-api -- loadLocalStorage is supported in Obsidian 1.7.2+
|
||||
if (this.app.loadLocalStorage("ols-history-highlightdiff") == "1") {
|
||||
if (loadDocumentHistoryPreference(this.app, DOCUMENT_HISTORY_PREFERENCE_KEYS.highlightDiff)) {
|
||||
this.showDiff = true;
|
||||
}
|
||||
// eslint-disable-next-line obsidianmd/no-unsupported-api -- loadLocalStorage is supported in Obsidian 1.7.2+
|
||||
if (this.app.loadLocalStorage("ols-history-diffonly") == "1") {
|
||||
if (loadDocumentHistoryPreference(this.app, DOCUMENT_HISTORY_PREFERENCE_KEYS.diffOnly)) {
|
||||
this.diffOnly = true;
|
||||
}
|
||||
}
|
||||
@@ -567,10 +571,10 @@ export class DocumentHistoryModal extends Modal {
|
||||
e.addEventListener("click", () => this.navigateSearch("next"));
|
||||
});
|
||||
|
||||
this.searchResultIndicator = searchRow.createEl("span", { text: "" });
|
||||
this.searchResultIndicator = searchRow.createSpan({ text: "" });
|
||||
this.searchResultIndicator.addClass("history-search-result-indicator");
|
||||
|
||||
this.searchProgressIndicator = searchRow.createEl("span", { text: "" });
|
||||
this.searchProgressIndicator = searchRow.createSpan({ text: "" });
|
||||
this.searchProgressIndicator.addClass("history-search-progress-indicator");
|
||||
|
||||
const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" });
|
||||
@@ -620,8 +624,11 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: Event) => {
|
||||
this.showDiff = checkbox.checked;
|
||||
// eslint-disable-next-line obsidianmd/no-unsupported-api -- saveLocalStorage is supported in Obsidian 1.7.2+
|
||||
this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null);
|
||||
saveDocumentHistoryPreference(
|
||||
this.app,
|
||||
DOCUMENT_HISTORY_PREFERENCE_KEYS.highlightDiff,
|
||||
this.showDiff
|
||||
);
|
||||
this.updateDiffNavVisibility();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
@@ -636,8 +643,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: Event) => {
|
||||
this.diffOnly = checkbox.checked;
|
||||
// eslint-disable-next-line obsidianmd/no-unsupported-api -- saveLocalStorage is supported in Obsidian 1.7.2+
|
||||
this.app.saveLocalStorage("ols-history-diffonly", this.diffOnly == true ? "1" : null);
|
||||
saveDocumentHistoryPreference(this.app, DOCUMENT_HISTORY_PREFERENCE_KEYS.diffOnly, this.diffOnly);
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
});
|
||||
@@ -663,7 +669,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
this.navigateDiff("next");
|
||||
});
|
||||
});
|
||||
this.diffNavIndicator = this.diffNavContainer.createEl("span", { text: "\u2014" });
|
||||
this.diffNavIndicator = this.diffNavContainer.createSpan({ text: "\u2014" });
|
||||
this.diffNavIndicator.addClass("diff-nav-indicator");
|
||||
|
||||
this.info = contentEl.createDiv("");
|
||||
@@ -718,7 +724,6 @@ export class DocumentHistoryModal extends Modal {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.BlobURLs.forEach((value) => {
|
||||
console.log(value);
|
||||
if (value) URL.revokeObjectURL(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { requireApiVersion, type App } from "@/deps.ts";
|
||||
|
||||
export const DOCUMENT_HISTORY_PREFERENCE_KEYS = {
|
||||
diffOnly: "ols-history-diffonly",
|
||||
highlightDiff: "ols-history-highlightdiff",
|
||||
} as const;
|
||||
|
||||
export type DocumentHistoryPreferenceKey =
|
||||
(typeof DOCUMENT_HISTORY_PREFERENCE_KEYS)[keyof typeof DOCUMENT_HISTORY_PREFERENCE_KEYS];
|
||||
|
||||
export function loadDocumentHistoryPreference(app: App, key: DocumentHistoryPreferenceKey): boolean {
|
||||
if (requireApiVersion("1.8.7")) {
|
||||
return app.loadLocalStorage(key) === "1";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function saveDocumentHistoryPreference(app: App, key: DocumentHistoryPreferenceKey, enabled: boolean): void {
|
||||
if (requireApiVersion("1.8.7")) {
|
||||
app.saveLocalStorage(key, enabled ? "1" : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const requireApiVersionMock = vi.hoisted(() => vi.fn<(version: string) => boolean>());
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
requireApiVersion: requireApiVersionMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
DOCUMENT_HISTORY_PREFERENCE_KEYS,
|
||||
loadDocumentHistoryPreference,
|
||||
saveDocumentHistoryPreference,
|
||||
} from "./documentHistoryPreferences.ts";
|
||||
|
||||
function createAppStorage() {
|
||||
return {
|
||||
loadLocalStorage: vi.fn<(key: string) => unknown>(),
|
||||
saveLocalStorage: vi.fn<(key: string, value: unknown | null) => void>(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("document history preferences", () => {
|
||||
beforeEach(() => {
|
||||
requireApiVersionMock.mockReset();
|
||||
});
|
||||
|
||||
it("falls back without accessing Vault local storage on older Obsidian versions", () => {
|
||||
requireApiVersionMock.mockReturnValue(false);
|
||||
const app = createAppStorage();
|
||||
|
||||
expect(loadDocumentHistoryPreference(app as never, DOCUMENT_HISTORY_PREFERENCE_KEYS.highlightDiff)).toBe(false);
|
||||
saveDocumentHistoryPreference(app as never, DOCUMENT_HISTORY_PREFERENCE_KEYS.diffOnly, true);
|
||||
|
||||
expect(requireApiVersionMock).toHaveBeenCalledWith("1.8.7");
|
||||
expect(app.loadLocalStorage).not.toHaveBeenCalled();
|
||||
expect(app.saveLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads and saves Vault-scoped preferences when the API is available", () => {
|
||||
requireApiVersionMock.mockReturnValue(true);
|
||||
const app = createAppStorage();
|
||||
app.loadLocalStorage.mockReturnValue("1");
|
||||
|
||||
expect(loadDocumentHistoryPreference(app as never, DOCUMENT_HISTORY_PREFERENCE_KEYS.diffOnly)).toBe(true);
|
||||
saveDocumentHistoryPreference(app as never, DOCUMENT_HISTORY_PREFERENCE_KEYS.highlightDiff, true);
|
||||
saveDocumentHistoryPreference(app as never, DOCUMENT_HISTORY_PREFERENCE_KEYS.highlightDiff, false);
|
||||
|
||||
expect(app.loadLocalStorage).toHaveBeenCalledWith("ols-history-diffonly");
|
||||
expect(app.saveLocalStorage).toHaveBeenNthCalledWith(1, "ols-history-highlightdiff", "1");
|
||||
expect(app.saveLocalStorage).toHaveBeenNthCalledWith(2, "ols-history-highlightdiff", null);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { AnyEntry, FilePathWithPrefix } from "@lib/common/types.ts";
|
||||
import { getDocData, isAnyNote, isDocContentSame, readAsBlob } from "@lib/common/utils.ts";
|
||||
import type { AnyEntry, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getDocData, isAnyNote, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { diff_match_patch } from "@/deps.ts";
|
||||
import { DocumentHistoryModal } from "@/modules/features/DocumentHistory/DocumentHistoryModal.ts";
|
||||
import { isPlainText, stripAllPrefixes } from "@lib/string_and_binary/path.ts";
|
||||
import { isPlainText, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let core: LiveSyncBaseCore;
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
import { App, Modal } from "@/deps.ts";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "@lib/common/types.ts";
|
||||
import { delay } from "@lib/common/utils.ts";
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
import { globalSlipBoard } from "@lib/bureau/bureau.ts";
|
||||
import {
|
||||
CANCELLED,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
type diff_result,
|
||||
type FilePathWithPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_CONFLICT_CANCELLED, eventHub } from "@/common/events.ts";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
export const POSTPONED = Symbol("postponed");
|
||||
|
||||
declare global {
|
||||
interface Slips extends LSSlips {
|
||||
"conflict-resolved": typeof CANCELLED | MergeDialogResult;
|
||||
}
|
||||
}
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
title?: string;
|
||||
localName?: string;
|
||||
remoteName?: string;
|
||||
};
|
||||
|
||||
export class ConflictResolveModal extends Modal {
|
||||
result: diff_result;
|
||||
filename: string;
|
||||
filename: FilePathWithPrefix;
|
||||
|
||||
response: MergeDialogResult = CANCELLED;
|
||||
isClosed = false;
|
||||
consumed = false;
|
||||
private readonly resultPromise = promiseWithResolvers<MergeDialogResult>();
|
||||
|
||||
title: string = "Conflicting changes";
|
||||
|
||||
pluginPickMode: boolean = false;
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
@@ -31,19 +40,28 @@ export class ConflictResolveModal extends Modal {
|
||||
diffView!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
|
||||
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
|
||||
constructor(
|
||||
app: App,
|
||||
filename: FilePathWithPrefix,
|
||||
diff: diff_result,
|
||||
pluginPickMode?: boolean,
|
||||
remoteName?: string,
|
||||
options?: ConflictResolveModalOptions
|
||||
) {
|
||||
super(app);
|
||||
this.result = diff;
|
||||
this.filename = filename;
|
||||
this.pluginPickMode = pluginPickMode || false;
|
||||
this.readOnly = options?.readOnly ?? false;
|
||||
if (this.pluginPickMode) {
|
||||
this.title = "Pick a version";
|
||||
this.remoteName = `${remoteName || "Remote"}`;
|
||||
this.localName = "Local";
|
||||
} else if (this.readOnly) {
|
||||
this.title = options?.title ?? "Vault and database revision";
|
||||
this.localName = options?.localName ?? "Vault file";
|
||||
this.remoteName = options?.remoteName ?? "Database revision";
|
||||
}
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
}
|
||||
|
||||
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
|
||||
@@ -94,23 +112,26 @@ export class ConflictResolveModal extends Modal {
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
globalSlipBoard.submit("conflict-resolved", this.filename, CANCELLED);
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
this.offEvent = eventHub.onEvent("conflict-cancelled", (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
if (!this.readOnly) {
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
diffOptionsRow.addClass("diff-options-row");
|
||||
diffOptionsRow.createEl("span", { text: this.filename });
|
||||
diffOptionsRow.createSpan({ text: this.filename });
|
||||
|
||||
const diffNavContainer = diffOptionsRow.createDiv("");
|
||||
diffNavContainer.addClass("diff-nav");
|
||||
@@ -122,7 +143,7 @@ export class ConflictResolveModal extends Modal {
|
||||
e.addClass("diff-nav-btn");
|
||||
e.addEventListener("click", () => this.navigateDiff("next"));
|
||||
});
|
||||
this.diffNavIndicator = diffNavContainer.createEl("span", { text: "\u2014" });
|
||||
this.diffNavIndicator = diffNavContainer.createSpan({ text: "\u2014" });
|
||||
this.diffNavIndicator.addClass("diff-nav-indicator");
|
||||
|
||||
this.diffView = contentEl.createDiv("");
|
||||
@@ -153,24 +174,32 @@ export class ConflictResolveModal extends Modal {
|
||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => {
|
||||
const actionContainer = contentEl.createDiv("conflict-action-container");
|
||||
if (this.readOnly) {
|
||||
actionContainer.createEl("button", { text: "Close" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
});
|
||||
} else {
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
}
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
});
|
||||
if (diffLength > 100 * 1024) {
|
||||
this.diffView.empty();
|
||||
this.diffView.setText("(Too large diff to display)");
|
||||
@@ -194,12 +223,10 @@ export class ConflictResolveModal extends Modal {
|
||||
return;
|
||||
}
|
||||
this.consumed = true;
|
||||
globalSlipBoard.submit("conflict-resolved", this.filename, this.response);
|
||||
this.resultPromise.resolve(this.response);
|
||||
}
|
||||
|
||||
async waitForResult(): Promise<MergeDialogResult> {
|
||||
await delay(100);
|
||||
const r = await globalSlipBoard.awaitNext("conflict-resolved", this.filename);
|
||||
return r;
|
||||
return await this.resultPromise.promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { POSTPONED, ConflictResolveModal } from "./ConflictResolveModal.ts";
|
||||
import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
Modal: class Modal {
|
||||
createdButtons: string[] = [];
|
||||
|
||||
private createElement(): Record<string, unknown> {
|
||||
const element: Record<string, unknown> = {
|
||||
addClass: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
appendText: vi.fn(),
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
empty: vi.fn(),
|
||||
querySelector: vi.fn(() => null),
|
||||
querySelectorAll: vi.fn(() => []),
|
||||
scrollIntoView: vi.fn(),
|
||||
setText: vi.fn(),
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
if (
|
||||
_tag === "button" &&
|
||||
typeof _options === "object" &&
|
||||
_options !== null &&
|
||||
"text" in _options
|
||||
) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
callback?.(child);
|
||||
return child;
|
||||
});
|
||||
element.createSpan = vi.fn(() => this.createElement());
|
||||
return element;
|
||||
}
|
||||
|
||||
contentEl = this.createElement();
|
||||
titleEl = {
|
||||
setText: vi.fn(),
|
||||
};
|
||||
|
||||
close() {
|
||||
(this as { onClose?: () => void }).onClose?.();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
describe("ConflictResolveModal result lifecycle", () => {
|
||||
it("returns a response which closes the dialogue before the caller begins waiting", async () => {
|
||||
const modal = new ConflictResolveModal({} as never, "early-response.md" as FilePathWithPrefix, conflict);
|
||||
|
||||
modal.sendResponse(POSTPONED);
|
||||
const result = await Promise.race([
|
||||
modal.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
|
||||
]);
|
||||
|
||||
expect(result).toBe(POSTPONED);
|
||||
});
|
||||
|
||||
it("cancels the previous same-path dialogue without cancelling the replacement", async () => {
|
||||
const filename = "same-path.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const replacement = new ConflictResolveModal({} as never, filename, conflict);
|
||||
previous.onOpen();
|
||||
|
||||
replacement.onOpen();
|
||||
const previousResult = await Promise.race([
|
||||
previous.waitForResult(),
|
||||
new Promise<"timed-out">((resolve) => setTimeout(() => resolve("timed-out"), 250)),
|
||||
]);
|
||||
const replacementState = await Promise.race([
|
||||
replacement.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
|
||||
previous.sendResponse(CANCELLED);
|
||||
replacement.sendResponse(CANCELLED);
|
||||
|
||||
expect(previousResult).toBe(CANCELLED);
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
it("renders a read-only comparison with no resolution actions", () => {
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal & { createdButtons: string[] };
|
||||
const modal = new ReadOnlyModal(
|
||||
{},
|
||||
"repair-preview.md",
|
||||
conflict,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
}
|
||||
);
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
expect(modal.createdButtons).toContain("Close");
|
||||
expect(modal.createdButtons).not.toContain("Use Vault file");
|
||||
expect(modal.createdButtons).not.toContain("Use Database revision");
|
||||
expect(modal.createdButtons).not.toContain("Concat both");
|
||||
expect(modal.createdButtons).not.toContain("Not now");
|
||||
modal.close();
|
||||
});
|
||||
|
||||
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
|
||||
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal;
|
||||
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
previous.onOpen();
|
||||
|
||||
comparison.onOpen();
|
||||
const previousState = await Promise.race([
|
||||
previous.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
|
||||
previous.sendResponse(CANCELLED);
|
||||
comparison.close();
|
||||
|
||||
expect(previousState).toBe("still-open");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { logMessages } from "@lib/mock_and_interop/stores";
|
||||
import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { reactive, type ReactiveInstance } from "octagonal-wheels/dataobject/reactive";
|
||||
import { Logger } from "@lib/common/logger";
|
||||
import { $msg as msg, currentLang as lang } from "@lib/common/i18n.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { $msg as msg, currentLang as lang } from "@/common/translation";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
let unsubscribe: () => void;
|
||||
let messages = $state([] as string[]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { WorkspaceLeaf } from "@/deps.ts";
|
||||
import LogPaneComponent from "./LogPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import { SvelteItemView } from "@/common/SvelteItemView.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { mount } from "svelte";
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
//Log view
|
||||
|
||||
@@ -8,16 +8,76 @@ import {
|
||||
type DocumentID,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@lib/common/types.ts";
|
||||
import { ConflictResolveModal } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConflictResolveModal, POSTPONED } from "./InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { displayRev } from "@/common/utils.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { EVENT_CONFLICT_CANCELLED, EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { $msg } from "@/common/translation.ts";
|
||||
import type { Editor, MarkdownFileInfo, MarkdownView } from "@/deps.ts";
|
||||
|
||||
export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
private postponedConflictEpisodes = new Set<FilePathWithPrefix>();
|
||||
|
||||
private async getConflictVersionCount(filename: FilePathWithPrefix): Promise<number | undefined> {
|
||||
try {
|
||||
const conflictCount = (await this.core.databaseFileAccess.getConflictedRevs(filename)).length;
|
||||
return conflictCount === 0 ? 0 : conflictCount + 1;
|
||||
} catch (error) {
|
||||
this._log(`Could not inspect the conflict state of ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(error, LOG_LEVEL_VERBOSE);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async getActiveConflictMessages(): Promise<string[]> {
|
||||
const filename = this.services.vault.getActiveFilePath();
|
||||
if (!filename) return [];
|
||||
const versionCount = await this.getConflictVersionCount(filename);
|
||||
if (versionCount === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
return [];
|
||||
}
|
||||
if (versionCount !== undefined && versionCount >= 3) {
|
||||
return [
|
||||
$msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", {
|
||||
COUNT: `${versionCount}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (versionCount === 2 || this.postponedConflictEpisodes.has(filename)) {
|
||||
return [$msg("This file has unresolved conflicts.")];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async refreshConflictState(filename: FilePathWithPrefix): Promise<void> {
|
||||
if ((await this.getConflictVersionCount(filename)) === 0) {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
}
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
|
||||
private async requestConflictResolution(filename: FilePathWithPrefix): Promise<void> {
|
||||
this.postponedConflictEpisodes.delete(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-checkdoc-conflicted",
|
||||
name: "Resolve if conflicted.",
|
||||
editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
|
||||
const file = view.file;
|
||||
if (!file) return;
|
||||
void this.requestConflictResolution(file.path as FilePathWithPrefix);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "livesync-conflictcheck",
|
||||
name: "Pick a file to resolve conflict",
|
||||
@@ -38,10 +98,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
async _anyResolveConflictByUI(filename: FilePathWithPrefix, conflictCheckResult: diff_result): Promise<boolean> {
|
||||
// UI for resolving conflicts should one-by-one.
|
||||
return await serialized(`conflict-resolve-ui`, async () => {
|
||||
if (this.postponedConflictEpisodes.has(filename)) {
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
return false;
|
||||
}
|
||||
this._log("Merge:open conflict dialog", LOG_LEVEL_VERBOSE);
|
||||
const dialog = new ConflictResolveModal(this.app, filename, conflictCheckResult);
|
||||
dialog.open();
|
||||
const selected = await dialog.waitForResult();
|
||||
if (selected === POSTPONED) {
|
||||
this.postponedConflictEpisodes.add(filename);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(`Merge: Postponed ${filename}`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (selected === CANCELLED) {
|
||||
// Cancelled by UI, or another conflict.
|
||||
this._log(`Merge: Cancelled ${filename}`, LOG_LEVEL_INFO);
|
||||
@@ -52,8 +123,21 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
this._log(`Merge: Could not read ${filename} from the local database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!testDoc._conflicts) {
|
||||
if (!testDoc._conflicts || testDoc._conflicts.length === 0) {
|
||||
this._log(`Merge: Nothing to do ${filename}`, LOG_LEVEL_VERBOSE);
|
||||
await this.refreshConflictState(filename);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
testDoc._rev !== conflictCheckResult.left.rev ||
|
||||
!testDoc._conflicts.includes(conflictCheckResult.right.rev)
|
||||
) {
|
||||
this._log(
|
||||
`Merge: The compared revisions changed while the dialogue was open: ${filename}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.refreshConflictState(filename);
|
||||
await this.services.conflict.queueCheckFor(filename);
|
||||
return false;
|
||||
}
|
||||
const toDelete = selected;
|
||||
@@ -62,7 +146,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
// Concatenate both conflicted revisions.
|
||||
// Create a new file by concatenating both conflicted revisions.
|
||||
const p = conflictCheckResult.diff.map((e) => e[1]).join("");
|
||||
const delRev = testDoc._conflicts[0];
|
||||
const delRev = conflictCheckResult.right.rev;
|
||||
if (!(await this.core.databaseFileAccess.storeContent(filename, p))) {
|
||||
this._log(`Concatenated content cannot be stored:${filename}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -78,7 +162,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else if (typeof toDelete === "string") {
|
||||
} else if (
|
||||
typeof toDelete === "string" &&
|
||||
(toDelete === conflictCheckResult.left.rev || toDelete === conflictCheckResult.right.rev)
|
||||
) {
|
||||
// Select one of the conflicted revision to delete.
|
||||
if (
|
||||
(await this.services.conflict.resolveByDeletingRevision(filename, toDelete, "UI Selected")) ==
|
||||
@@ -88,7 +175,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete as string})`, LOG_LEVEL_NOTICE);
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${String(toDelete)})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
@@ -103,10 +190,13 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
});
|
||||
}
|
||||
async allConflictCheck() {
|
||||
while (await this.pickFileForResolve());
|
||||
let notifyIfEmpty = true;
|
||||
while (await this.pickFileForResolve(notifyIfEmpty)) {
|
||||
notifyIfEmpty = false;
|
||||
}
|
||||
}
|
||||
|
||||
async pickFileForResolve() {
|
||||
async pickFileForResolve(notifyIfEmpty = true) {
|
||||
const notes: { id: DocumentID; path: FilePathWithPrefix; dispPath: string; mtime: number }[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (!("_conflicts" in doc)) continue;
|
||||
@@ -120,14 +210,15 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
notes.sort((a, b) => b.mtime - a.mtime);
|
||||
const notesList = notes.map((e) => e.dispPath);
|
||||
if (notesList.length == 0) {
|
||||
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
if (notifyIfEmpty) {
|
||||
this._log("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const target = await this.core.confirm.askSelectString("File to resolve conflict", notesList);
|
||||
if (target) {
|
||||
const targetItem = notes.find((e) => e.dispPath == target)!;
|
||||
await this.services.conflict.queueCheckFor(targetItem.path);
|
||||
await this.services.conflict.ensureAllProcessed();
|
||||
await this.requestConflictResolution(targetItem.path);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -172,6 +263,10 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this.getActiveConflictMessages.bind(this));
|
||||
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
|
||||
eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (filename) => {
|
||||
fireAndForget(() => this.refreshConflictState(filename));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTO_MERGED,
|
||||
CANCELLED,
|
||||
DEFAULT_SETTINGS,
|
||||
LEAVE_TO_SUBSEQUENT,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePathWithPrefix,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const modalState = vi.hoisted(() => ({
|
||||
constructed: 0,
|
||||
result: undefined as unknown,
|
||||
postponed: Symbol("postponed"),
|
||||
}));
|
||||
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
displayRev: (revision: string) => revision,
|
||||
}));
|
||||
|
||||
vi.mock("./InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
POSTPONED: modalState.postponed,
|
||||
ConflictResolveModal: class ConflictResolveModal {
|
||||
constructor() {
|
||||
modalState.constructed++;
|
||||
}
|
||||
|
||||
open() {}
|
||||
|
||||
async waitForResult() {
|
||||
return modalState.result;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { ModuleInteractiveConflictResolver } from "./ModuleInteractiveConflictResolver.ts";
|
||||
|
||||
const path = "note.md" as FilePathWithPrefix;
|
||||
const conflict: diff_result = {
|
||||
left: { rev: "2-left", data: "left", ctime: 1, mtime: 2 },
|
||||
right: { rev: "2-right", data: "right", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
};
|
||||
|
||||
async function* documents(items: unknown[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
function createModule(conflictedRevisions: string[] = ["2-right"]) {
|
||||
const handlers = {
|
||||
unresolvedMessages: undefined as undefined | (() => Promise<string[]>),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: {
|
||||
addHandler: vi.fn((handler: () => Promise<string[]>) => {
|
||||
handlers.unresolvedMessages = handler;
|
||||
}),
|
||||
},
|
||||
onScanningStartupIssues: { addHandler: vi.fn() },
|
||||
onInitialise: { addHandler: vi.fn() },
|
||||
isSuspended: vi.fn(() => false),
|
||||
},
|
||||
conflict: {
|
||||
resolveByUserInteraction: { addHandler: vi.fn() },
|
||||
resolveByDeletingRevision: vi.fn(async () => AUTO_MERGED),
|
||||
queueCheckFor: vi.fn(async () => undefined),
|
||||
ensureAllProcessed: vi.fn(async () => true),
|
||||
},
|
||||
replication: { replicateByEvent: vi.fn(async () => true) },
|
||||
vault: { getActiveFilePath: vi.fn(() => path) },
|
||||
path: { getPath: vi.fn((entry: { path: FilePathWithPrefix }) => entry.path) },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: { ...DEFAULT_SETTINGS, syncAfterMerge: false },
|
||||
localDatabase: {
|
||||
getDBEntry: vi.fn(async (): Promise<false | { _rev: string; _conflicts?: string[] }> => false),
|
||||
findAllDocs: vi.fn(() => documents([])),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
getConflictedRevs: vi.fn(async () => conflictedRevisions),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
confirm: {
|
||||
askSelectString: vi.fn(async (): Promise<string | undefined> => undefined),
|
||||
},
|
||||
};
|
||||
const plugin = { app: {} };
|
||||
const module = new ModuleInteractiveConflictResolver(plugin as never, core as never);
|
||||
module._log = vi.fn();
|
||||
return { core, handlers, module, services };
|
||||
}
|
||||
|
||||
describe("ModuleInteractiveConflictResolver postponement", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not reopen an unchanged conflict after the user chooses Not now", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not treat cancellation by another conflict dialogue as Not now", async () => {
|
||||
const { module } = createModule();
|
||||
modalState.result = CANCELLED;
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("allows an explicit resolution request to reopen a postponed conflict", async () => {
|
||||
const { module, services } = createModule();
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
await (module as any).requestConflictResolution(path);
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
expect(services.conflict.ensureAllProcessed).toHaveBeenCalledOnce();
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("opens a later conflict after the postponed conflict episode has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { module } = createModule(conflictedRevisions);
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.splice(0);
|
||||
await (module as any).refreshConflictState(path);
|
||||
conflictedRevisions.push("4-later");
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("contributes the active conflict to the existing unresolved-message display", async () => {
|
||||
const { core, handlers, module, services } = createModule();
|
||||
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
expect(services.appLifecycle.getUnresolvedMessages.addHandler).toHaveBeenCalledOnce();
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
});
|
||||
|
||||
it("removes the active warning once the conflict has resolved", async () => {
|
||||
const conflictedRevisions = ["2-right"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
conflictedRevisions.splice(0);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reports the number of live versions and reduces it after each resolved pair", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const { core, handlers, module, services } = createModule(conflictedRevisions);
|
||||
module.onBindFunction(core as never, services as never);
|
||||
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has 3 unresolved versions. They will be reviewed one pair at a time.",
|
||||
]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual(["This file has unresolved conflicts."]);
|
||||
|
||||
conflictedRevisions.shift();
|
||||
await (module as any).refreshConflictState(path);
|
||||
await expect(handlers.unresolvedMessages?.()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("reconstructs the remaining pair after a postponed session is restarted", async () => {
|
||||
const conflictedRevisions = ["2-second", "2-third"];
|
||||
const firstSession = createModule(conflictedRevisions);
|
||||
|
||||
await firstSession.module._anyResolveConflictByUI(path, conflict);
|
||||
conflictedRevisions.shift();
|
||||
|
||||
const restartedSession = createModule(conflictedRevisions);
|
||||
restartedSession.module.onBindFunction(restartedSession.core as never, restartedSession.services as never);
|
||||
await expect(restartedSession.handlers.unresolvedMessages?.()).resolves.toEqual([
|
||||
"This file has unresolved conflicts.",
|
||||
]);
|
||||
|
||||
await restartedSession.module._anyResolveConflictByUI(path, {
|
||||
left: { rev: "3-merged", data: "merged", ctime: 1, mtime: 3 },
|
||||
right: { rev: "2-third", data: "third", ctime: 1, mtime: 2 },
|
||||
diff: [],
|
||||
});
|
||||
|
||||
expect(modalState.constructed).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes the compared right leaf when concatenating a deterministically selected pair", async () => {
|
||||
const { core, module, services } = createModule(["2-unrelated", "2-right"]);
|
||||
modalState.result = LEAVE_TO_SUBSEQUENT;
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-unrelated", "2-right"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(core.databaseFileAccess.storeContent).toHaveBeenCalledWith(path, "");
|
||||
expect(services.conflict.resolveByDeletingRevision).toHaveBeenCalledWith(path, "2-right", "UI Concatenated");
|
||||
});
|
||||
|
||||
it("rechecks the live leaves instead of applying a stale dialogue selection", async () => {
|
||||
const { core, module, services } = createModule(["2-other"]);
|
||||
modalState.result = "2-right";
|
||||
core.localDatabase.getDBEntry.mockResolvedValue({
|
||||
_rev: "3-new-winner",
|
||||
_conflicts: ["2-other"],
|
||||
});
|
||||
|
||||
await module._anyResolveConflictByUI(path, conflict);
|
||||
|
||||
expect(services.conflict.resolveByDeletingRevision).not.toHaveBeenCalled();
|
||||
expect(services.conflict.queueCheckFor).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModuleInteractiveConflictResolver file selection", () => {
|
||||
beforeEach(() => {
|
||||
modalState.constructed = 0;
|
||||
modalState.result = modalState.postponed;
|
||||
});
|
||||
|
||||
it("does not show a no-conflicts notice when an automatic repeat reaches its normal end", async () => {
|
||||
const { core, module } = createModule();
|
||||
core.localDatabase.findAllDocs
|
||||
.mockImplementationOnce(() =>
|
||||
documents([
|
||||
{
|
||||
_id: "note-id",
|
||||
_rev: "2-left",
|
||||
_conflicts: ["2-right"],
|
||||
path,
|
||||
mtime: 2,
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => documents([]));
|
||||
core.confirm.askSelectString.mockResolvedValue(path);
|
||||
|
||||
await module.allConflictCheck();
|
||||
|
||||
expect(core.confirm.askSelectString).toHaveBeenCalledOnce();
|
||||
expect(module._log).not.toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("shows one no-conflicts notice for an explicit selection request which starts empty", async () => {
|
||||
const { module } = createModule();
|
||||
|
||||
await module.pickFileForResolve();
|
||||
|
||||
expect(module._log).toHaveBeenCalledTimes(1);
|
||||
expect(module._log).toHaveBeenCalledWith("There are no conflicted documents", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
PREFIXMD_LOGFILE,
|
||||
type DatabaseConnectingStatus,
|
||||
type LOG_LEVEL,
|
||||
} from "@lib/common/types.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { cancelTask, scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { fireAndForget, isDirty, throttle } from "@lib/common/utils.ts";
|
||||
import { fireAndForget, isDirty, throttle } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import {
|
||||
collectingChunks,
|
||||
pluginScanningCount,
|
||||
@@ -16,32 +16,38 @@ import {
|
||||
hiddenFilesProcessingCount,
|
||||
type LogEntry,
|
||||
logMessages,
|
||||
} from "@lib/mock_and_interop/stores.ts";
|
||||
import { eventHub } from "@lib/hub/hub.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import {
|
||||
EVENT_FILE_RENAMED,
|
||||
EVENT_LAYOUT_READY,
|
||||
EVENT_LEAF_ACTIVE_CHANGED,
|
||||
EVENT_ON_UNRESOLVED_ERROR,
|
||||
eventHub,
|
||||
} from "@/common/events.ts";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type WorkspaceLeaf } from "@/deps.ts";
|
||||
import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector";
|
||||
import {
|
||||
REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
import { createMinimumVisibleActivityCount, createPaddedCounterLabel } from "./StatusBarDisplay.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
import {
|
||||
isValidFilenameInAndroid,
|
||||
isValidFilenameInDarwin,
|
||||
isValidFilenameInWidows,
|
||||
} from "@lib/string_and_binary/path.ts";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts";
|
||||
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
@@ -114,46 +120,46 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
statusLog = reactiveSource("");
|
||||
activeFileStatus = reactiveSource("");
|
||||
notifies: { [key: string]: { notice: Notice; count: number } } = {};
|
||||
p2pLogCollector = new P2PLogCollector();
|
||||
p2pLogCollector = new P2PLogCollector(this.services.context.events);
|
||||
|
||||
observeForLogs() {
|
||||
const padSpaces = `\u{2007}`.repeat(10);
|
||||
// const emptyMark = `\u{2003}`;
|
||||
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) {
|
||||
const formatted = reactiveSource("");
|
||||
let timer: number | undefined = undefined;
|
||||
let maxLen = 1;
|
||||
numI.onChanged((numX) => {
|
||||
const num = numX.value;
|
||||
const numLen = `${Math.abs(num)}`.length + 1;
|
||||
maxLen = maxLen < numLen ? numLen : maxLen;
|
||||
if (timer) compatGlobal.clearTimeout(timer);
|
||||
if (num == 0) {
|
||||
timer = compatGlobal.setTimeout(() => {
|
||||
formatted.value = "";
|
||||
maxLen = 1;
|
||||
}, 3000);
|
||||
}
|
||||
formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-maxLen)}`;
|
||||
});
|
||||
return computed(() => formatted.value);
|
||||
}
|
||||
const labelReplication = padLeftSpComputed(this.services.replication.replicationResultCount, `📥`);
|
||||
const labelDBCount = padLeftSpComputed(this.services.replication.databaseQueueCount, `📄`);
|
||||
const labelStorageCount = padLeftSpComputed(this.services.replication.storageApplyingCount, `💾`);
|
||||
const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`);
|
||||
const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`);
|
||||
const labelConflictProcessCount = padLeftSpComputed(this.services.conflict.conflictProcessQueueCount, `🔩`);
|
||||
const registerDisplay = <T extends { dispose(): void }>(display: T): T => {
|
||||
this.plugin.register(() => display.dispose());
|
||||
return display;
|
||||
};
|
||||
const labelReplication = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.replicationResultCount, `📥`)
|
||||
);
|
||||
const labelDBCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.databaseQueueCount, `📄`)
|
||||
);
|
||||
const labelStorageCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.replication.storageApplyingCount, `💾`)
|
||||
);
|
||||
const labelChunkCount = registerDisplay(createPaddedCounterLabel(collectingChunks, `🧩`));
|
||||
const labelPluginScanCount = registerDisplay(createPaddedCounterLabel(pluginScanningCount, `🔌`));
|
||||
const labelConflictProcessCount = registerDisplay(
|
||||
createPaddedCounterLabel(this.services.conflict.conflictProcessQueueCount, `🔩`)
|
||||
);
|
||||
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value);
|
||||
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`);
|
||||
const labelHiddenFilesCount = registerDisplay(createPaddedCounterLabel(hiddenFilesCount, `⚙️`));
|
||||
const queueCountLabelX = reactive(() => {
|
||||
return `${labelReplication()}${labelDBCount()}${labelStorageCount()}${labelChunkCount()}${labelPluginScanCount()}${labelHiddenFilesCount()}${labelConflictProcessCount()}`;
|
||||
return `${labelReplication.value}${labelDBCount.value}${labelStorageCount.value}${labelChunkCount.value}${labelPluginScanCount.value}${labelHiddenFilesCount.value}${labelConflictProcessCount.value}`;
|
||||
});
|
||||
const queueCountLabel = () => queueCountLabelX.value;
|
||||
|
||||
const trackedRequestCount = reactive(() => {
|
||||
return getTrackedRequestCount(this.services.API.requestCount.value, this.services.API.responseCount.value);
|
||||
});
|
||||
const displayedTrackedRequestCount = registerDisplay(
|
||||
createMinimumVisibleActivityCount(trackedRequestCount, REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS)
|
||||
);
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
const diff = this.services.API.requestCount.value - this.services.API.responseCount.value;
|
||||
return diff != 0 ? "📲 " : "";
|
||||
return formatRemoteActivityStatusLabel({
|
||||
remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value),
|
||||
trackedRequestCount: displayedTrackedRequestCount.value,
|
||||
});
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
@@ -209,11 +215,11 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
});
|
||||
const labelProc = padLeftSpComputed(this.services.fileProcessing.processing, `⏳`);
|
||||
const labelPend = padLeftSpComputed(this.services.fileProcessing.totalQueued, `🛫`);
|
||||
const labelInBatchDelay = padLeftSpComputed(this.services.fileProcessing.batched, `📬`);
|
||||
const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `⏳`));
|
||||
const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `🛫`));
|
||||
const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `📬`));
|
||||
const waitingLabel = computed(() => {
|
||||
return `${labelProc()}${labelPend()}${labelInBatchDelay()}`;
|
||||
return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`;
|
||||
});
|
||||
const statusLineLabel = computed(() => {
|
||||
const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel();
|
||||
@@ -545,13 +551,6 @@ ${stringifyYaml(info)}
|
||||
return;
|
||||
}
|
||||
addDisplayLog(newMessage);
|
||||
if (message instanceof Error) {
|
||||
console.error(vaultName + ":" + newMessage);
|
||||
} else if (level >= LOG_LEVEL_INFO) {
|
||||
console.log(vaultName + ":" + newMessage);
|
||||
} else {
|
||||
console.debug(vaultName + ":" + newMessage);
|
||||
}
|
||||
if (!this.settings?.showOnlyIconsOnEditor) {
|
||||
this.statusLog.value = messageContent;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type TFile } from "@/deps.ts";
|
||||
import { eventHub } from "@/common/events.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
||||
import type { FilePathWithPrefix, LoadedEntry, DocumentID } from "@lib/common/types.ts";
|
||||
import type { FilePathWithPrefix, LoadedEntry, DocumentID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
import { DocumentHistoryModal } from "./DocumentHistory/DocumentHistoryModal.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
import { isObjectDifferent } from "octagonal-wheels/object";
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { parseYaml, stringifyYaml } from "@/deps";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseYaml, stringifyYaml, type Editor, type MarkdownView } from "@/deps";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase.ts";
|
||||
import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
const SETTING_HEADER = "````yaml:livesync-setting\n";
|
||||
const SETTING_FOOTER = "\n````";
|
||||
@@ -28,7 +32,7 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
this.addCommand({
|
||||
id: "livesync-import-config",
|
||||
name: "Parse setting file",
|
||||
editorCheckCallback: (checking, editor, ctx) => {
|
||||
editorCheckCallback: (checking: boolean, editor: Editor, ctx: MarkdownView) => {
|
||||
if (checking) {
|
||||
const doc = editor.getValue();
|
||||
const ret = this.extractSettingFromWholeText(doc);
|
||||
@@ -104,7 +108,11 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
const { body } = await this.parseSettingFromMarkdown(filename);
|
||||
let newSetting = {} as Partial<ObsidianLiveSyncSettings>;
|
||||
try {
|
||||
newSetting = parseYaml(body);
|
||||
const parsed: unknown = parseYaml(body);
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new TypeError("The YAML settings must contain an object");
|
||||
}
|
||||
newSetting = parsed;
|
||||
} catch (ex) {
|
||||
this._log("Could not parse YAML", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AbstractObsidianModule } from "@/modules/AbstractObsidianModule.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
import { EVENT_REQUEST_OPEN_SETTING_WIZARD, EVENT_REQUEST_OPEN_SETTINGS, eventHub } from "@/common/events.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { openObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
|
||||
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
settingTab!: ObsidianLiveSyncSettingTab;
|
||||
@@ -20,11 +21,7 @@ export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
openSetting() {
|
||||
// Undocumented API
|
||||
//@ts-ignore
|
||||
this.app.setting.open();
|
||||
//@ts-ignore
|
||||
this.app.setting.openTabById("obsidian-livesync");
|
||||
openObsidianSettings(this.app, "obsidian-livesync");
|
||||
}
|
||||
|
||||
get appId() {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Status icon for a finite remote operation whose lifetime is known. */
|
||||
export const REMOTE_OPERATION_ACTIVITY_ICON = "📲";
|
||||
|
||||
/** Status icon for approximate physical remote-request activity. */
|
||||
export const REMOTE_REQUEST_ACTIVITY_ICON = "🌐";
|
||||
|
||||
/** Avoids hiding very short remote requests before the status bar can render them. */
|
||||
export const REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS = 150;
|
||||
|
||||
export type RemoteActivityStatus = {
|
||||
remoteOperationCount: number;
|
||||
trackedRequestCount: number;
|
||||
};
|
||||
|
||||
/** Returns the non-negative difference between tracked request starts and completions. */
|
||||
export function getTrackedRequestCount(requestCount: number, responseCount: number): number {
|
||||
return Math.max(0, requestCount - responseCount);
|
||||
}
|
||||
|
||||
/** Formats the compact prefix shown before the replication status. */
|
||||
export function formatRemoteActivityStatusLabel(status: RemoteActivityStatus): string {
|
||||
const labels = [
|
||||
status.remoteOperationCount > 0 ? REMOTE_OPERATION_ACTIVITY_ICON : "",
|
||||
status.trackedRequestCount > 0 ? `${REMOTE_REQUEST_ACTIVITY_ICON}${status.trackedRequestCount}` : "",
|
||||
].filter((label) => label !== "");
|
||||
return labels.length > 0 ? `${labels.join(" ")} ` : "";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
REMOTE_OPERATION_ACTIVITY_ICON,
|
||||
REMOTE_REQUEST_ACTIVITY_ICON,
|
||||
formatRemoteActivityStatusLabel,
|
||||
getTrackedRequestCount,
|
||||
} from "./RemoteActivityStatus.ts";
|
||||
|
||||
describe("getTrackedRequestCount", () => {
|
||||
it("reports the non-negative difference between starts and completions", () => {
|
||||
expect(getTrackedRequestCount(3, 2)).toBe(1);
|
||||
expect(getTrackedRequestCount(2, 2)).toBe(0);
|
||||
expect(getTrackedRequestCount(2, 3)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRemoteActivityStatusLabel", () => {
|
||||
it("separates a finite remote operation from tracked physical requests", () => {
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 0 })).toBe(
|
||||
`${REMOTE_OPERATION_ACTIVITY_ICON} `
|
||||
);
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 1 })).toBe(
|
||||
`${REMOTE_REQUEST_ACTIVITY_ICON}1 `
|
||||
);
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 1, trackedRequestCount: 2 })).toBe(
|
||||
`${REMOTE_OPERATION_ACTIVITY_ICON} ${REMOTE_REQUEST_ACTIVITY_ICON}2 `
|
||||
);
|
||||
});
|
||||
|
||||
it("omits inactive and invalid negative activity counts", () => {
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: 0, trackedRequestCount: 0 })).toBe("");
|
||||
expect(formatRemoteActivityStatusLabel({ remoteOperationCount: -1, trackedRequestCount: -1 })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Mostly used in the Setting Dialogue
|
||||
*/
|
||||
import { type SveltePanelProps } from "./SveltePanel";
|
||||
import InfoTable from "@lib/UI/components/InfoTable.svelte";
|
||||
import InfoTable from "@/modules/services/LiveSyncUI/components/InfoTable.svelte";
|
||||
type Props = SveltePanelProps<{
|
||||
info: Record<string, any>;
|
||||
}>;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ValueComponent,
|
||||
} from "@/deps.ts";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@lib/common/types.ts";
|
||||
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type AllNumericItemKey,
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
|
||||
|
||||
export class LiveSyncSetting extends Setting {
|
||||
@@ -206,7 +206,8 @@ export class LiveSyncSetting extends Setting {
|
||||
const setValue = wrapMemo((value: boolean) => {
|
||||
toggle.setValue(opt?.invert ? !value : value);
|
||||
});
|
||||
this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] ?? false);
|
||||
this.invalidateValue = () =>
|
||||
setValue(LiveSyncSetting.env.editingSettings[key] ?? opt?.defaultToggleValue ?? false);
|
||||
this.invalidateValue();
|
||||
|
||||
toggle.onChange(async (value) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CustomRegExpSource } from "@lib/common/types";
|
||||
import { isInvertedRegExp, isValidRegExp } from "@lib/common/utils";
|
||||
import type { CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isInvertedRegExp, isValidRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
|
||||
export let patterns = [] as CustomRegExpSource[];
|
||||
export let originals = [] as CustomRegExpSource[];
|
||||
|
||||
@@ -12,15 +12,14 @@ import {
|
||||
LEVEL_ADVANCED,
|
||||
LEVEL_EDGE_CASE,
|
||||
REMOTE_P2P,
|
||||
} from "@lib/common/types.ts";
|
||||
import { delay, isObjectDifferent, sizeToHumanReadable } from "@lib/common/utils.ts";
|
||||
import { versionNumberString2Number } from "@lib/string_and_binary/convert.ts";
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
import { checkSyncInfo } from "@lib/pouchdb/negotiation.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { checkSyncInfo } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import { testCrypt } from "octagonal-wheels/encryption/encryption";
|
||||
import ObsidianLiveSyncPlugin from "@/main.ts";
|
||||
import { scheduleTask } from "@/common/utils.ts";
|
||||
import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator.ts";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
type AllStringItemKey,
|
||||
@@ -31,10 +30,9 @@ import {
|
||||
type OnDialogSettings,
|
||||
getConfName,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { fireAndForget, yieldNextAnimationFrame } from "octagonal-wheels/promises";
|
||||
import { confirmWithMessage } from "@/modules/coreObsidian/UILib/dialogs.ts";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, eventHub } from "@/common/events.ts";
|
||||
import { paneChangeLog } from "./PaneChangeLog.ts";
|
||||
import {
|
||||
@@ -62,9 +60,10 @@ import { paneAdvanced } from "./PaneAdvanced.ts";
|
||||
import { panePowerUsers } from "./PanePowerUsers.ts";
|
||||
import { panePatches } from "./PanePatches.ts";
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { JournalSyncCore } from "@lib/replication/journal/JournalSyncCore.js";
|
||||
import { MinioStorageAdapter } from "@lib/replication/journal/objectstore/MinioStorageAdapter.js";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replication/journal/objectstore/MinioStorageAdapter";
|
||||
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
@@ -101,6 +100,14 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
// Buffered Settings for comparing.
|
||||
initialSettings?: typeof this.editingSettings;
|
||||
|
||||
private copySettingValue(target: object | undefined, source: object, key: AllSettingItemKey): void {
|
||||
if (!target) {
|
||||
throw new Error("Initial settings have not been loaded");
|
||||
}
|
||||
const value: unknown = Reflect.get(source, key);
|
||||
Reflect.set(target, key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply editing setting to the plug-in.
|
||||
* @param keys setting keys for applying
|
||||
@@ -113,10 +120,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
// this.initialSettings[k] = this.editingSettings[k];
|
||||
continue;
|
||||
}
|
||||
//@ts-ignore
|
||||
this.core.settings[k] = this.editingSettings[k];
|
||||
//@ts-ignore
|
||||
this.initialSettings[k] = this.core.settings[k];
|
||||
this.copySettingValue(this.core.settings, this.editingSettings, k);
|
||||
this.copySettingValue(this.initialSettings, this.core.settings, k);
|
||||
}
|
||||
keys.forEach((e) => this.refreshSetting(e));
|
||||
}
|
||||
@@ -151,14 +156,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
appliedKeys.push(k);
|
||||
if (k in OnDialogSettingsDefault) {
|
||||
await this.saveLocalSetting(k as keyof OnDialogSettings);
|
||||
//@ts-ignore
|
||||
this.initialSettings[k] = this.editingSettings[k];
|
||||
this.copySettingValue(this.initialSettings, this.editingSettings, k);
|
||||
continue;
|
||||
}
|
||||
//@ts-ignore
|
||||
this.core.settings[k] = this.editingSettings[k];
|
||||
//@ts-ignore
|
||||
this.initialSettings[k] = this.core.settings[k];
|
||||
this.copySettingValue(this.core.settings, this.editingSettings, k);
|
||||
this.copySettingValue(this.initialSettings, this.core.settings, k);
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
@@ -236,15 +238,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
const localSetting = this.reloadAllLocalSettings();
|
||||
if (key in this.core.settings) {
|
||||
if (key in localSetting) {
|
||||
//@ts-ignore
|
||||
this.initialSettings[key] = localSetting[key];
|
||||
//@ts-ignore
|
||||
this.editingSettings[key] = localSetting[key];
|
||||
this.copySettingValue(this.initialSettings, localSetting, key);
|
||||
this.copySettingValue(this.editingSettings, localSetting, key);
|
||||
} else {
|
||||
//@ts-ignore
|
||||
this.initialSettings[key] = this.core.settings[key];
|
||||
//@ts-ignore
|
||||
this.editingSettings[key] = this.initialSettings[key];
|
||||
this.copySettingValue(this.initialSettings, this.core.settings, key);
|
||||
this.copySettingValue(this.editingSettings, this.initialSettings ?? {}, key);
|
||||
}
|
||||
}
|
||||
this.editingSettings = { ...this.editingSettings, ...this.computeAllLocalSettings() };
|
||||
@@ -310,8 +308,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
|
||||
closeSetting() {
|
||||
//@ts-ignore :
|
||||
this.plugin.app.setting.close();
|
||||
closeObsidianSettings(this.plugin.app);
|
||||
}
|
||||
|
||||
handleElement(element: HTMLElement, func: OnUpdateFunc) {
|
||||
@@ -417,11 +414,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
}
|
||||
|
||||
//@ts-ignore
|
||||
manifestVersion: string = MANIFEST_VERSION || "-";
|
||||
|
||||
lastVersion = ~~(versionNumberString2Number(this.manifestVersion) / 1000);
|
||||
|
||||
screenElements: { [key: string]: HTMLElement[] } = {};
|
||||
changeDisplay(screen: string) {
|
||||
for (const k in this.screenElements) {
|
||||
@@ -480,7 +472,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
isNeedRebuildLocal() {
|
||||
return this.isSomeDirty([
|
||||
"useIndexedDBAdapter",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
"handleFilenameCaseSensitive",
|
||||
"passphrase",
|
||||
"useDynamicIterationCount",
|
||||
@@ -491,7 +482,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
isNeedRebuildRemote() {
|
||||
return this.isSomeDirty([
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
"handleFilenameCaseSensitive",
|
||||
"passphrase",
|
||||
"useDynamicIterationCount",
|
||||
@@ -623,7 +613,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
OPTION_ONLY_SETTING,
|
||||
OPTION_CANCEL,
|
||||
];
|
||||
const result = await confirmWithMessage(this.plugin, title, note, buttons, OPTION_CANCEL, 0);
|
||||
const result = await this.core.confirm.confirmWithMessage(title, note, buttons, OPTION_CANCEL);
|
||||
if (result == OPTION_CANCEL) return;
|
||||
if (result == OPTION_FETCH) {
|
||||
if (!(await this.checkWorkingPassphrase())) {
|
||||
@@ -736,7 +726,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
value: `${order}`,
|
||||
cls: "sls-setting-tab",
|
||||
} as DomElementInfo);
|
||||
el.createEl("div", {
|
||||
el.createDiv({
|
||||
cls: "sls-setting-menu-btn",
|
||||
text: icon,
|
||||
title: title,
|
||||
@@ -829,18 +819,10 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
|
||||
void yieldNextAnimationFrame().then(() => {
|
||||
if (this.selectedScreen == "") {
|
||||
if (this.lastVersion != this.editingSettings.lastReadUpdates) {
|
||||
if (this.editingSettings.isConfigured) {
|
||||
changeDisplay("100");
|
||||
} else {
|
||||
changeDisplay("110");
|
||||
}
|
||||
if (this.isAnySyncEnabled()) {
|
||||
changeDisplay("20");
|
||||
} else {
|
||||
if (this.isAnySyncEnabled()) {
|
||||
changeDisplay("20");
|
||||
} else {
|
||||
changeDisplay("110");
|
||||
}
|
||||
changeDisplay("110");
|
||||
}
|
||||
} else {
|
||||
changeDisplay(this.selectedScreen);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChunkAlgorithmNames } from "@lib/common/types.ts";
|
||||
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
@@ -35,7 +35,9 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
clampMin: 10,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
|
||||
new Setting(paneEl)
|
||||
.setClass("wizardHidden")
|
||||
.autoWireToggle("autoAcceptCompatibleTweak", { defaultToggleValue: true });
|
||||
// new Setting(paneEl)
|
||||
// .setClass("wizardHidden")
|
||||
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
|
||||
@@ -45,4 +47,7 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
// clampMax: 100, clampMin: 1, onUpdate: onlyOnCouchDB
|
||||
// })
|
||||
});
|
||||
void addPanel(paneEl, "Remote Database Tweak").then((paneEl) => {
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("enableCompression");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,61 +1,11 @@
|
||||
import { MarkdownRenderer } from "@/deps.ts";
|
||||
import { versionNumberString2Number } from "@lib/string_and_binary/convert.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
//@ts-ignore
|
||||
const manifestVersion: string = MANIFEST_VERSION || "-";
|
||||
//@ts-ignore
|
||||
declare const UPDATE_INFO: string;
|
||||
const updateInformation: string = UPDATE_INFO || "";
|
||||
|
||||
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
|
||||
export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement): void {
|
||||
const cx = this.createEl(
|
||||
paneEl,
|
||||
"div",
|
||||
{
|
||||
cls: "op-warn-info",
|
||||
},
|
||||
undefined,
|
||||
visibleOnly(() => !this.isConfiguredAs("versionUpFlash", ""))
|
||||
);
|
||||
this.createEl(
|
||||
cx,
|
||||
"div",
|
||||
{
|
||||
text: this.editingSettings.versionUpFlash,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
this.createEl(cx, "button", { text: $msg("obsidianLiveSyncSettingTab.btnGotItAndUpdated") }, (e) => {
|
||||
e.addClass("mod-cta");
|
||||
e.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
this.editingSettings.versionUpFlash = "";
|
||||
await this.saveAllDirtySettings();
|
||||
});
|
||||
});
|
||||
});
|
||||
const informationDivEl = this.createEl(paneEl, "div", { text: "" });
|
||||
const tmpDiv = createDiv();
|
||||
// tmpDiv.addClass("sls-header-button");
|
||||
tmpDiv.addClass("op-warn-info");
|
||||
|
||||
tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
|
||||
const readEverythingButton = tmpDiv.createEl("button", {
|
||||
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
|
||||
});
|
||||
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
|
||||
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
|
||||
readEverythingButton.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
this.editingSettings.lastReadUpdates = lastVersion;
|
||||
await this.saveAllDirtySettings();
|
||||
informationButtonDiv.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
fireAndForget(() =>
|
||||
MarkdownRenderer.render(this.plugin.app, updateInformation, informationDivEl, "/", this.lifetimeComponent)
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { $msg, $t } from "@lib/common/i18n.ts";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta.ts";
|
||||
import { $msg, $t } from "@/common/translation";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
export function paneGeneral(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import { EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "@lib/common/logger.ts";
|
||||
import { FlagFilesHumanReadable, FLAGMD_REDFLAG } from "@lib/common/types.ts";
|
||||
import { fireAndForget } from "@lib/common/utils.ts";
|
||||
import { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { FlagFilesHumanReadable, FLAGMD_REDFLAG } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import { visibleOnly, type PageFunctions } from "./SettingPane";
|
||||
@@ -187,7 +187,7 @@ export function paneMaintenance(
|
||||
)
|
||||
.addOnUpdate(this.onlyOnMinIO);
|
||||
});
|
||||
void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnP2POrCouchDB).then((paneEl) => {
|
||||
void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnCouchDB).then((paneEl) => {
|
||||
new Setting(paneEl)
|
||||
.setName("Perform Garbage Collection")
|
||||
.setDesc("Perform Garbage Collection to remove unused chunks and reduce database size.")
|
||||
|
||||
@@ -4,14 +4,14 @@ import {
|
||||
type HashAlgorithm,
|
||||
LOG_LEVEL_NOTICE,
|
||||
SuffixDatabaseName,
|
||||
} from "@lib/common/types.ts";
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { PouchDB } from "@lib/pouchdb/pouchdb-browser";
|
||||
import { ExtraSuffixIndexedDB } from "@lib/common/types.ts";
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { migrateDatabases } from "./settingUtils.ts";
|
||||
|
||||
export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
@@ -188,7 +188,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
text.inputEl.before((dateEl = activeDocument.createElement("span")));
|
||||
text.inputEl.before((dateEl = activeDocument.createSpan()));
|
||||
text.inputEl.type = "datetime-local";
|
||||
if (this.editingSettings.maxMTimeForReflectEvents > 0) {
|
||||
const date = new Date(this.editingSettings.maxMTimeForReflectEvents);
|
||||
@@ -231,15 +231,4 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
}
|
||||
});
|
||||
});
|
||||
void addPanel(paneEl, "Remote Database Tweak (In sunset)").then((paneEl) => {
|
||||
// new Setting(paneEl).autoWireToggle("useEden").setClass("wizardHidden");
|
||||
// const onlyUsingEden = visibleOnly(() => this.isConfiguredAs("useEden", true));
|
||||
// new Setting(paneEl).autoWireNumeric("maxChunksInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden");
|
||||
// new Setting(paneEl)
|
||||
// .autoWireNumeric("maxTotalLengthInEden", { onUpdate: onlyUsingEden })
|
||||
// .setClass("wizardHidden");
|
||||
// new Setting(paneEl).autoWireNumeric("maxAgeInEden", { onUpdate: onlyUsingEden }).setClass("wizardHidden");
|
||||
|
||||
new Setting(paneEl).autoWireToggle("enableCompression").setClass("wizardHidden");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ConfigPassphraseStore } from "@lib/common/types.ts";
|
||||
import { type ConfigPassphraseStore } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
LOG_LEVEL_NOTICE,
|
||||
type ObsidianLiveSyncSettings,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "@lib/common/types.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Menu, type ButtonComponent } from "@/deps.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
@@ -16,23 +16,23 @@ import type { PageFunctions } from "./SettingPane.ts";
|
||||
import InfoPanel from "./InfoPanel.svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import { SveltePanel } from "./SveltePanel.ts";
|
||||
import {
|
||||
getBucketConfigSummary,
|
||||
getP2PConfigSummary,
|
||||
getCouchDBConfigSummary,
|
||||
getE2EEConfigSummary,
|
||||
} from "./settingUtils.ts";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types.ts";
|
||||
import { getE2EEConfigSummary } from "./settingUtils.ts";
|
||||
import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
||||
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
|
||||
import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig.ts";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
|
||||
import type { RemoteConfigurationResult } from "@lib/common/ConnectionString.ts";
|
||||
import type { RemoteConfiguration } from "@lib/common/models/setting.type.ts";
|
||||
import {
|
||||
activateRemoteConfiguration,
|
||||
type RemoteConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import type {
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
|
||||
|
||||
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
||||
@@ -43,15 +43,6 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
|
||||
}
|
||||
return workObj;
|
||||
}
|
||||
const toggleActiveSyncClass = (el: HTMLElement, isActive: () => boolean) => {
|
||||
if (isActive()) {
|
||||
el.addClass("active-pane");
|
||||
} else {
|
||||
el.removeClass("active-pane");
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
function createRemoteConfigurationId(): string {
|
||||
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -142,8 +133,8 @@ export function paneRemoteConfig(
|
||||
}
|
||||
{
|
||||
// TODO: very WIP. need to refactor the UI.
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleRemoteServer"), () => {}).then((paneEl) => {
|
||||
const actions = new Setting(paneEl).setName("Remote Databases");
|
||||
void addPanel(paneEl, $msg("Connection settings"), () => {}).then((paneEl) => {
|
||||
const actions = new Setting(paneEl).setName($msg("Saved connections"));
|
||||
// actions.addButton((button) =>
|
||||
// button
|
||||
// .setButtonText("Change Remote and Setup")
|
||||
@@ -229,7 +220,13 @@ export function paneRemoteConfig(
|
||||
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
|
||||
}
|
||||
|
||||
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
|
||||
const couchConf = await dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, {
|
||||
settings: baseSettings,
|
||||
mode: "settings",
|
||||
});
|
||||
if (couchConf === "cancelled" || typeof couchConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
@@ -517,123 +514,6 @@ export function paneRemoteConfig(
|
||||
refreshList();
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const initialProps = {
|
||||
info: getCouchDBConfigSummary(this.editingSettings),
|
||||
};
|
||||
const summaryWritable = writable(initialProps);
|
||||
const updateSummary = () => {
|
||||
summaryWritable.set({
|
||||
info: getCouchDBConfigSummary(this.editingSettings),
|
||||
});
|
||||
};
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleCouchDB"), () => {}).then((paneEl) => {
|
||||
new SveltePanel(InfoPanel, paneEl, summaryWritable);
|
||||
const setupButton = new Setting(paneEl).setName("Configure Remote");
|
||||
setupButton
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Configure")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
await setupManager.onCouchDBManualSetup(
|
||||
UserMode.Update,
|
||||
originalSettings,
|
||||
this.editingSettings.remoteType === REMOTE_COUCHDB
|
||||
);
|
||||
|
||||
updateSummary();
|
||||
})
|
||||
)
|
||||
.addOnUpdate(() =>
|
||||
toggleActiveSyncClass(paneEl, () => this.editingSettings.remoteType === REMOTE_COUCHDB)
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const initialProps = {
|
||||
info: getBucketConfigSummary(this.editingSettings),
|
||||
};
|
||||
const summaryWritable = writable(initialProps);
|
||||
const updateSummary = () => {
|
||||
summaryWritable.set({
|
||||
info: getBucketConfigSummary(this.editingSettings),
|
||||
});
|
||||
};
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleMinioS3R2"), () => {}).then((paneEl) => {
|
||||
new SveltePanel(InfoPanel, paneEl, summaryWritable);
|
||||
const setupButton = new Setting(paneEl).setName("Configure Remote");
|
||||
setupButton
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Configure")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
await setupManager.onBucketManualSetup(
|
||||
UserMode.Update,
|
||||
originalSettings,
|
||||
this.editingSettings.remoteType === REMOTE_MINIO
|
||||
);
|
||||
//TODO
|
||||
updateSummary();
|
||||
})
|
||||
)
|
||||
.addOnUpdate(() =>
|
||||
toggleActiveSyncClass(paneEl, () => this.editingSettings.remoteType === REMOTE_MINIO)
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const getDevicePeerId = () => this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || "";
|
||||
const initialProps = {
|
||||
info: getP2PConfigSummary(this.editingSettings, {
|
||||
"Device Peer ID": getDevicePeerId(),
|
||||
}),
|
||||
};
|
||||
const summaryWritable = writable(initialProps);
|
||||
const updateSummary = () => {
|
||||
summaryWritable.set({
|
||||
info: getP2PConfigSummary(this.editingSettings, {
|
||||
"Device Peer ID": getDevicePeerId(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
void addPanel(paneEl, "Peer-to-Peer Synchronisation", () => {}).then((paneEl) => {
|
||||
new SveltePanel(InfoPanel, paneEl, summaryWritable);
|
||||
const setupButton = new Setting(paneEl).setName("Configure Remote");
|
||||
setupButton
|
||||
.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Configure")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
await setupManager.onP2PManualSetup(
|
||||
UserMode.Update,
|
||||
originalSettings,
|
||||
this.editingSettings.remoteType === REMOTE_P2P
|
||||
);
|
||||
//TODO
|
||||
updateSummary();
|
||||
})
|
||||
)
|
||||
.addOnUpdate(() =>
|
||||
toggleActiveSyncClass(
|
||||
paneEl,
|
||||
() => this.editingSettings.remoteType === REMOTE_P2P || this.editingSettings.P2P_Enabled
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// new Setting(paneEl)
|
||||
// .setDesc("Generate ES256 Keypair for testing")
|
||||
// .addButton((button) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LEVEL_ADVANCED, type CustomRegExpSource } from "@lib/common/types.ts";
|
||||
import { constructCustomRegExpList, splitCustomRegExpList } from "@lib/common/utils.ts";
|
||||
import { LEVEL_ADVANCED, type CustomRegExpSource } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { constructCustomRegExpList, splitCustomRegExpList } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import MultipleRegExpControl from "./MultipleRegExpControl.svelte";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { mount } from "svelte";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MarkdownRenderer } from "@/deps.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import {
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { DEFAULT_SETTINGS } from "@lib/common/types.ts";
|
||||
import { request } from "@/deps.ts";
|
||||
import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import {
|
||||
createCoreSettingsAfterFullReset,
|
||||
createEditingSettingsAfterFullReset,
|
||||
} from "@/serviceFeatures/setupObsidian/settingsReset.ts";
|
||||
export function paneSetup(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
@@ -37,8 +40,7 @@ export function paneSetup(
|
||||
.addButton((text) => {
|
||||
text.setButtonText($msg("Rerun Wizard")).onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
await setupManager.onOnboard(UserMode.ExistingUser);
|
||||
// await this.plugin.moduleSetupObsidian.onBoardingWizard(true);
|
||||
await setupManager.startOnBoarding();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,9 +94,9 @@ export function paneSetup(
|
||||
{ defaultOption: "No" }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.editingSettings = { ...this.editingSettings, ...DEFAULT_SETTINGS };
|
||||
this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings);
|
||||
await this.saveAllDirtySettings();
|
||||
this.core.settings = { ...DEFAULT_SETTINGS };
|
||||
this.core.settings = createCoreSettingsAfterFullReset();
|
||||
await this.services.setting.saveSettingData();
|
||||
await this.services.database.resetDatabase();
|
||||
// await this.plugin.initializeDatabase();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, REMOTE_COUCHDB, LEVEL_ADVANCED } from "@lib/common/types.ts";
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, REMOTE_COUCHDB, LEVEL_ADVANCED } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import { EVENT_REQUEST_COPY_SETUP_URI, eventHub } from "@/common/events.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
@@ -222,8 +222,6 @@ export function paneSyncSettings(
|
||||
LEVEL_ADVANCED
|
||||
).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("trashInsteadDelete");
|
||||
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("doNotDeleteFolder");
|
||||
});
|
||||
void addPanel(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { $msg } from "@lib/common/i18n";
|
||||
import { LEVEL_ADVANCED, LEVEL_EDGE_CASE, LEVEL_POWER_USER, type ConfigLevel } from "@lib/common/types";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { LEVEL_ADVANCED, LEVEL_EDGE_CASE, LEVEL_POWER_USER, type ConfigLevel } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { AllSettingItemKey, AllSettings } from "./settingConstants";
|
||||
|
||||
export const combineOnUpdate = (func1: OnUpdateFunc, func2: OnUpdateFunc): OnUpdateFunc => {
|
||||
@@ -75,6 +75,7 @@ export type AutoWireOption = {
|
||||
holdValue?: boolean;
|
||||
isPassword?: boolean;
|
||||
invert?: boolean;
|
||||
defaultToggleValue?: boolean;
|
||||
onUpdate?: OnUpdateFunc;
|
||||
obsolete?: boolean;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@lib/common/utils.ts";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types.ts";
|
||||
import { pickBucketSyncSettings, pickCouchDBSyncSettings, pickP2PSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
// Keep the setting dialogue buffer aligned with the current core settings before persisting other dirty keys.
|
||||
// This also clears stale dirty values left from editing a different remote type before switching active remotes.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/types";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
|
||||
|
||||
describe("syncActivatedRemoteSettings", () => {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "@lib/common/settingConstants.ts";
|
||||
export * from "@vrtmrz/livesync-commonlib/compat/common/settingConstants";
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { escapeStringToHTML } from "octagonal-wheels/string";
|
||||
import { E2EEAlgorithmNames, MILESTONE_DOCID, NODEINFO_DOCID, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { E2EEAlgorithmNames, MILESTONE_DOCID, NODEINFO_DOCID, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
pickCouchDBSyncSettings,
|
||||
pickBucketSyncSettings,
|
||||
pickP2PSyncSettings,
|
||||
pickEncryptionSettings,
|
||||
} from "@lib/common/utils";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { getConfig, type AllSettingItemKey } from "./settingConstants";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { isNotFoundError } from "@lib/common/utils.doc";
|
||||
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import type {} from "pouchdb-replication";
|
||||
|
||||
/**
|
||||
* Generates a summary of P2P configuration settings
|
||||
@@ -119,8 +121,7 @@ export async function migrateDatabases(operationName: string, from: PouchDB.Data
|
||||
Logger(`Destroyed existing destination database for migration: ${operationName}.`, LOG_LEVEL_NOTICE, "migration");
|
||||
|
||||
const dbTo2 = await openTo();
|
||||
const info2 = await dbTo2.info(); // ensure created
|
||||
console.log(info2);
|
||||
await dbTo2.info(); // ensure created
|
||||
Logger(`Re-created destination database for migration: ${operationName}.`, LOG_LEVEL_NOTICE, "migration");
|
||||
|
||||
const info = await from.info();
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { requestToCouchDBWithCredentials } from "@/common/utils";
|
||||
import { $msg } from "@lib/common/i18n";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { fireAndForget, parseHeaderValues } from "@lib/common/utils";
|
||||
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
|
||||
import { generateCredentialObject } from "@lib/replication/httplib";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { isUnauthorizedError } from "@lib/common/utils.doc";
|
||||
import { $msg } from "@/common/translation";
|
||||
import {
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { fireAndForget, parseHeaderValues } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isCloudantURI } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb";
|
||||
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||
|
||||
export const checkConfig = async (
|
||||
checkResultDiv: HTMLDivElement | undefined,
|
||||
@@ -43,7 +49,7 @@ export const checkConfig = async (
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseConfig = r.json;
|
||||
const responseConfig = normaliseCouchDBConfiguration(r.json as unknown);
|
||||
|
||||
const addConfigFixButton = (title: string, key: string, value: string) => {
|
||||
if (!checkResultDiv) return;
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type CouchDBConnection,
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "@lib/common/types.ts";
|
||||
import { isObjectDifferent } from "@lib/common/utils.ts";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
||||
@@ -25,9 +24,8 @@ import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte"
|
||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "@lib/API/processSetting.ts";
|
||||
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
|
||||
import type {
|
||||
OutroAskUserModeResultType,
|
||||
OutroExistingUserResultType,
|
||||
@@ -35,11 +33,24 @@ import type {
|
||||
ScanQRCodeResultType,
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
UseSetupURIResultType,
|
||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
import {
|
||||
applySettingsAndFetchOnActivation,
|
||||
applySettingsWithScheduledInitialisation,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
|
||||
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
...settings,
|
||||
remoteConfigurations: { ...(settings.remoteConfigurations ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
@@ -60,7 +71,7 @@ export const enum UserMode {
|
||||
/**
|
||||
* Update User Mode - for users who are updating configuration. May be `existing-user` as well, but possibly they want to treat it differently.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values -- Update is a semantic alias for the unknown setup mode.
|
||||
Update = "unknown", // Alias for Unknown for better readability
|
||||
}
|
||||
|
||||
@@ -99,7 +110,7 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
*/
|
||||
async onOnboard(userMode: UserMode): Promise<boolean> {
|
||||
const originalSetting = userMode === UserMode.NewUser ? DEFAULT_SETTINGS : this.core.settings;
|
||||
const originalSetting = userMode === UserMode.NewUser ? createNewVaultSettings() : this.core.settings;
|
||||
if (userMode === UserMode.NewUser) {
|
||||
//Ask how to apply initial setup
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodNewUser);
|
||||
@@ -158,20 +169,30 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
|
||||
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
CouchDBConnection
|
||||
>(SetupRemoteCouchDB, originalSetting);
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, {
|
||||
settings: currentSetting,
|
||||
mode:
|
||||
userMode === UserMode.NewUser
|
||||
? "create-or-connect"
|
||||
: userMode === UserMode.ExistingUser
|
||||
? "connect-existing"
|
||||
: "settings",
|
||||
});
|
||||
if (couchConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...baseSetting, ...couchConf } as ObsidianLiveSyncSettings;
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...couchConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
@@ -195,10 +216,14 @@ export class SetupManager extends AbstractModule {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...bucketConf } as ObsidianLiveSyncSettings;
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...bucketConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
@@ -222,26 +247,15 @@ export class SetupManager extends AbstractModule {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
|
||||
// Apply remoteConfigurations
|
||||
if (newSetting.P2P_ActiveRemoteConfigurationId) {
|
||||
const id = newSetting.P2P_ActiveRemoteConfigurationId;
|
||||
const merged = {
|
||||
...newSetting,
|
||||
...p2pConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
|
||||
newSetting.remoteConfigurations[id] = {
|
||||
...newSetting.remoteConfigurations[id],
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
};
|
||||
newSetting.P2P_ActiveRemoteConfigurationId = id;
|
||||
}
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_P2P;
|
||||
newSetting.activeConfigurationId = newSetting.P2P_ActiveRemoteConfigurationId;
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...p2pConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
|
||||
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
|
||||
activate,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
@@ -341,9 +355,9 @@ export class SetupManager extends AbstractModule {
|
||||
// console.dir(patch);
|
||||
if (!activate) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Setting Applied", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
const applied = await this.applySettingAndScheduleFetchOnActivation(newConf, UserMode.ExistingUser);
|
||||
if (applied) this._log("Setting Applied", LOG_LEVEL_NOTICE);
|
||||
return applied;
|
||||
}
|
||||
// Check virtual changes
|
||||
const original = { ...this.settings, P2P_DevicePeerName: "" } as ObsidianLiveSyncSettings;
|
||||
@@ -351,9 +365,9 @@ export class SetupManager extends AbstractModule {
|
||||
const isOnlyVirtualChange = isObjectDifferent(original, modified, true) === false;
|
||||
if (isOnlyVirtualChange) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
const applied = await this.applySettingAndScheduleFetchOnActivation(newConf, UserMode.ExistingUser);
|
||||
if (applied) this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return applied;
|
||||
} else {
|
||||
const userModeResult =
|
||||
await this.dialogManager.openWithExplicitCancel<OutroAskUserModeResultType>(OutroAskUserMode);
|
||||
@@ -363,9 +377,9 @@ export class SetupManager extends AbstractModule {
|
||||
userMode = UserMode.ExistingUser;
|
||||
} else if (userModeResult === "compatible-existing-user") {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
const applied = await this.applySettingAndScheduleFetchOnActivation(newConf, UserMode.ExistingUser);
|
||||
if (applied) this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return applied;
|
||||
} else if (userModeResult === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -374,21 +388,26 @@ export class SetupManager extends AbstractModule {
|
||||
}
|
||||
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
|
||||
const confirm = await this.dialogManager.openWithExplicitCancel<
|
||||
OutroNewUserResultType | OutroExistingUserResultType
|
||||
>(component);
|
||||
OutroNewUserResultType | OutroExistingUserResultType,
|
||||
{ isP2P: boolean }
|
||||
>(component, { isP2P: isP2PMainRemote(newConf) });
|
||||
if (confirm === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (confirm) {
|
||||
extra();
|
||||
await this.applySetting(newConf, userMode);
|
||||
if (userMode === UserMode.NewUser) {
|
||||
// For new users, schedule a rebuild everything.
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
// Reserve Rebuild before enabling the imported settings, so
|
||||
// the current runtime cannot begin ordinary processing first.
|
||||
await applySettingsWithScheduledInitialisation(this.core.rebuilder, "rebuild", async () => {
|
||||
await this.applySetting(newConf, userMode);
|
||||
});
|
||||
} else {
|
||||
// For existing users, schedule a fetch.
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
// Existing data must be fetched before the ordinary startup scan.
|
||||
await applySettingsWithScheduledInitialisation(this.core.rebuilder, "fetch", async () => {
|
||||
await this.applySetting(newConf, userMode);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Settings applied, but may require rebuild to take effect.
|
||||
@@ -430,4 +449,19 @@ export class SetupManager extends AbstractModule {
|
||||
await this.services.setting.applyExternalSettings(newConf, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applySettingAndScheduleFetchOnActivation(
|
||||
newConf: ObsidianLiveSyncSettings,
|
||||
userMode: UserMode
|
||||
): Promise<boolean> {
|
||||
const wasConfigured = this.settings.isConfigured;
|
||||
return await applySettingsAndFetchOnActivation(
|
||||
this.core.rebuilder,
|
||||
wasConfigured,
|
||||
newConf.isConfigured,
|
||||
async () => {
|
||||
await this.applySetting(newConf, userMode);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { SettingService } from "@lib/services/base/SettingService";
|
||||
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
|
||||
@@ -17,11 +23,11 @@ vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
|
||||
|
||||
vi.mock("../../lib/src/API/processSetting.ts", () => ({
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => ({
|
||||
decodeSettingsFromQRCodeData: vi.fn(),
|
||||
}));
|
||||
|
||||
import { decodeSettingsFromQRCodeData } from "@lib/API/processSetting.ts";
|
||||
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { SetupManager, UserMode } from "./SetupManager";
|
||||
|
||||
class TestSettingService extends SettingService<ServiceContext> {
|
||||
@@ -93,8 +99,14 @@ function createSetupManager() {
|
||||
const core: any = {
|
||||
_services: services,
|
||||
rebuilder: {
|
||||
scheduleRebuild: vi.fn(() => Promise.resolve()),
|
||||
scheduleFetch: vi.fn(() => Promise.resolve()),
|
||||
scheduleRebuild: vi.fn(async (prepareBeforeRestart?: () => Promise<void>) => {
|
||||
await prepareBeforeRestart?.();
|
||||
return true;
|
||||
}),
|
||||
scheduleFetch: vi.fn(async (prepareBeforeRestart?: () => Promise<void>) => {
|
||||
await prepareBeforeRestart?.();
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(core, "services", {
|
||||
@@ -125,7 +137,17 @@ describe("SetupManager", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => {
|
||||
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
|
||||
|
||||
await manager.onOnboard(UserMode.NewUser);
|
||||
|
||||
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
|
||||
});
|
||||
|
||||
it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(createLegacyRemoteSetting())
|
||||
@@ -140,7 +162,7 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("decodeQR should normalise imported legacy remote settings before applying", async () => {
|
||||
it("compatibility: normalises imported flat remote settings from QR data before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user");
|
||||
@@ -154,4 +176,397 @@ describe("SetupManager", () => {
|
||||
);
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("reserves Rebuild before saving a new-user configuration", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: false };
|
||||
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onConfirmApplySettingsFromWizard(
|
||||
{ ...createLegacyRemoteSetting(), isConfigured: true },
|
||||
UserMode.NewUser
|
||||
);
|
||||
|
||||
expect(core.rebuilder.scheduleRebuild).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(core.rebuilder.scheduleRebuild.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
applyExternalSettings.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(setting.currentSettings().isConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("identifies P2P when opening the new-user initialisation confirmation", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: false };
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true);
|
||||
const p2pProfileId = "p2p-profile";
|
||||
|
||||
await manager.onConfirmApplySettingsFromWizard(
|
||||
{
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
// Imported profile settings can still carry the previous compatibility field
|
||||
// until the selected profile is projected by the setting lifecycle.
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
activeConfigurationId: p2pProfileId,
|
||||
remoteConfigurations: {
|
||||
[p2pProfileId]: {
|
||||
id: p2pProfileId,
|
||||
name: "P2P room",
|
||||
uri: "sls+p2p://:secret@team-room?relays=wss%3A%2F%2Frelay.example",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
UserMode.NewUser
|
||||
);
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
|
||||
isP2P: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves Fetch when compatible imported settings activate an unconfigured device", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: false };
|
||||
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({ ...createLegacyRemoteSetting(), isConfigured: true })
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings");
|
||||
|
||||
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(core.rebuilder.scheduleFetch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
applyExternalSettings.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(setting.currentSettings().isConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("applies compatible settings to an already configured device without scheduling Fetch", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: true };
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({ ...createLegacyRemoteSetting(), isConfigured: true })
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings");
|
||||
|
||||
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(setting.currentSettings().isConfigured).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enable imported settings when the initialisation flag cannot be reserved", async () => {
|
||||
const { manager, setting, dialogManager, core } = createSetupManager();
|
||||
setting.settings = { ...setting.currentSettings(), isConfigured: false };
|
||||
const applyExternalSettings = vi.spyOn(setting, "applyExternalSettings");
|
||||
core.rebuilder.scheduleRebuild.mockResolvedValueOnce(false);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onConfirmApplySettingsFromWizard(
|
||||
{ ...createLegacyRemoteSetting(), isConfigured: true },
|
||||
UserMode.NewUser
|
||||
);
|
||||
|
||||
expect(core.rebuilder.scheduleRebuild).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(setting.currentSettings().isConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const imported = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {
|
||||
couch: {
|
||||
id: "couch",
|
||||
name: "Office CouchDB",
|
||||
uri: "sls+https://alice:secret@couch.example/?db=notes",
|
||||
isEncrypted: false,
|
||||
},
|
||||
archive: {
|
||||
id: "archive",
|
||||
name: "Archive bucket",
|
||||
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive®ion=auto",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "archive",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(imported)
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
|
||||
expect(current.activeConfigurationId).toBe("archive");
|
||||
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
id: "existing",
|
||||
name: "Existing remote",
|
||||
uri: "sls+http://old:secret@old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "existing",
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
couchDB_URI: "https://couch.example",
|
||||
couchDB_USER: "alice",
|
||||
couchDB_PASSWORD: "secret",
|
||||
couchDB_DBNAME: "notes",
|
||||
couchDB_CustomHeaders: "",
|
||||
useJWT: false,
|
||||
jwtAlgorithm: "",
|
||||
jwtKey: "",
|
||||
jwtKid: "",
|
||||
jwtSub: "",
|
||||
jwtExpDuration: 5,
|
||||
useRequestAPI: false,
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onCouchDBManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).not.toBe("existing");
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.name).toBe("CouchDB couch.example");
|
||||
expect(activeProfile?.uri).toContain("sls+https://alice:secret@couch.example");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[UserMode.NewUser, "create-or-connect"],
|
||||
[UserMode.ExistingUser, "connect-existing"],
|
||||
[UserMode.Update, "settings"],
|
||||
] as const)(
|
||||
"passes the %s CouchDB database policy to the manual setup dialogue",
|
||||
async (userMode, expectedMode) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const couchConf = {
|
||||
couchDB_URI: "https://couch.example",
|
||||
couchDB_USER: "alice",
|
||||
couchDB_PASSWORD: "secret",
|
||||
couchDB_DBNAME: "notes",
|
||||
couchDB_CustomHeaders: "",
|
||||
useJWT: false,
|
||||
jwtAlgorithm: "",
|
||||
jwtKey: "",
|
||||
jwtKid: "",
|
||||
jwtSub: "",
|
||||
jwtExpDuration: 5,
|
||||
useRequestAPI: false,
|
||||
};
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce(couchConf).mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onCouchDBManualSetup(userMode, setting.currentSettings());
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(1, expect.anything(), {
|
||||
settings: setting.currentSettings(),
|
||||
mode: expectedMode,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("adds and activates a manually configured Object Storage profile without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
id: "existing",
|
||||
name: "Existing remote",
|
||||
uri: "sls+http://old:secret@old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "existing",
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
endpoint: "https://storage.example",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
bucket: "notes",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onBucketManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).not.toBe("existing");
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.name).toBe("S3 notes");
|
||||
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
|
||||
});
|
||||
|
||||
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: false,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
P2P_ActiveRemoteConfigurationId: "",
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
P2P_Enabled: true,
|
||||
P2P_roomID: "team-room",
|
||||
P2P_passphrase: "secret",
|
||||
P2P_relays: "wss://relay.example",
|
||||
P2P_AppID: "self-hosted-livesync",
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_turnServers: "",
|
||||
P2P_turnUsername: "",
|
||||
P2P_turnCredential: "",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onP2PManualSetup(UserMode.NewUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(1);
|
||||
expect(current.activeConfigurationId).not.toBe("");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).toBe(current.activeConfigurationId);
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.name).toBe("P2P team-room");
|
||||
expect(activeProfile?.uri).toContain("sls+p2p://");
|
||||
});
|
||||
|
||||
it("selects a configured P2P profile without replacing the active main remote", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
main: {
|
||||
id: "main",
|
||||
name: "Main CouchDB",
|
||||
uri: "sls+http://old:secret@old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "main",
|
||||
P2P_ActiveRemoteConfigurationId: "",
|
||||
};
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
||||
P2P_Enabled: true,
|
||||
P2P_roomID: "team-room",
|
||||
P2P_passphrase: "secret",
|
||||
P2P_relays: "wss://relay.example",
|
||||
P2P_AppID: "self-hosted-livesync",
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_turnServers: "",
|
||||
P2P_turnUsername: "",
|
||||
P2P_turnCredential: "",
|
||||
});
|
||||
|
||||
await manager.onP2PManualSetup(UserMode.Unknown, setting.currentSettings(), false);
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
expect(current.activeConfigurationId).toBe("main");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).not.toBe("");
|
||||
expect(current.P2P_ActiveRemoteConfigurationId).not.toBe("main");
|
||||
expect(current.remoteConfigurations[current.P2P_ActiveRemoteConfigurationId]?.name).toBe("P2P team-room");
|
||||
});
|
||||
|
||||
it("does not register Object Storage when final confirmation is cancelled", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
id: "existing",
|
||||
name: "Existing remote",
|
||||
uri: "sls+http://old:secret@old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "existing",
|
||||
};
|
||||
const before = structuredClone(setting.currentSettings().remoteConfigurations);
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
endpoint: "https://storage.example",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
bucket: "notes",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
})
|
||||
.mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onBucketManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
expect(setting.currentSettings().remoteConfigurations).toEqual(before);
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("existing");
|
||||
});
|
||||
|
||||
it("does not mutate an existing P2P profile when final confirmation is cancelled", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
id: "existing",
|
||||
name: "Existing P2P remote",
|
||||
uri: "sls+p2p://old-room?passphrase=old-secret",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "existing",
|
||||
P2P_ActiveRemoteConfigurationId: "existing",
|
||||
};
|
||||
const before = structuredClone(setting.currentSettings().remoteConfigurations);
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
P2P_Enabled: true,
|
||||
P2P_roomID: "new-room",
|
||||
P2P_passphrase: "new-secret",
|
||||
P2P_relays: "wss://relay.example",
|
||||
P2P_AppID: "self-hosted-livesync",
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_turnServers: "",
|
||||
P2P_turnUsername: "",
|
||||
P2P_turnCredential: "",
|
||||
})
|
||||
.mockResolvedValueOnce("cancelled");
|
||||
|
||||
await manager.onP2PManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
expect(setting.currentSettings().remoteConfigurations).toEqual(before);
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("existing");
|
||||
expect(setting.currentSettings().P2P_ActiveRemoteConfigurationId).toBe("existing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import Check from "@lib/UI/components/Check.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
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 {
|
||||
TYPE_BACKUP_DONE,
|
||||
TYPE_BACKUP_SKIPPED,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: IntroResultType) => void;
|
||||
@@ -30,6 +32,11 @@
|
||||
|
||||
<DialogHeader title="Welcome to Self-hosted LiveSync" />
|
||||
<Guidance>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>
|
||||
<Options>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import {
|
||||
type OutroAskUserModeResultType,
|
||||
TYPE_CANCELLED,
|
||||
|
||||
@@ -1,36 +1,70 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
|
||||
type Props = {
|
||||
setResult: (result: OutroExistingUserResultType) => void;
|
||||
getInitialData?: () => { isP2P?: boolean } | undefined;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const isP2P = $derived(getInitialData?.()?.isP2P === true);
|
||||
</script>
|
||||
|
||||
<DialogHeader title="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
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>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.
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>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)} />
|
||||
</UserDecisions>
|
||||
{#if isP2P}
|
||||
<DialogHeader title={translateMessage("Setup Complete: Preparing to Fetch from Another Device")} />
|
||||
<Guidance>
|
||||
<p>
|
||||
{translateMessage(
|
||||
"The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device."
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>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."
|
||||
)}
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>
|
||||
{translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")}
|
||||
</Question>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision
|
||||
title={translateMessage("Restart and Select Source Device")}
|
||||
important={true}
|
||||
commit={() => setResult(TYPE_APPLY)}
|
||||
/>
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{:else}
|
||||
<DialogHeader title="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
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>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.
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>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)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -1,37 +1,63 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as msg } from "@/common/translation";
|
||||
import { TYPE_APPLY, TYPE_CANCELLED, type OutroNewUserResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: OutroNewUserResultType) => void;
|
||||
getInitialData?: () => { isP2P?: boolean } | undefined;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const isP2P = $derived(getInitialData?.()?.isP2P === true);
|
||||
// let userType = $state<OutroNewUserResultType>(TYPE_CANCELLED);
|
||||
</script>
|
||||
|
||||
<DialogHeader title="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
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>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.
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>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)} />
|
||||
</UserDecisions>
|
||||
{#if isP2P}
|
||||
<DialogHeader title={msg("Ui.SetupWizard.OutroNewP2PUser.Title")} />
|
||||
<Guidance>
|
||||
<p>{msg("Ui.SetupWizard.OutroNewP2PUser.GuidancePrimary")}</p>
|
||||
<p>
|
||||
<strong>{msg("Ui.SetupWizard.OutroNewP2PUser.Important")}</strong>
|
||||
<br />
|
||||
{msg("Ui.SetupWizard.OutroNewP2PUser.GuidanceNotice")}
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>{msg("Ui.SetupWizard.OutroNewP2PUser.Question")}</Question>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision
|
||||
title={msg("Ui.SetupWizard.OutroNewP2PUser.Proceed")}
|
||||
important={true}
|
||||
commit={() => setResult(TYPE_APPLY)}
|
||||
/>
|
||||
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{:else}
|
||||
<DialogHeader title="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
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>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.
|
||||
</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>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)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -2,23 +2,27 @@
|
||||
/**
|
||||
* Panel to check and fix CouchDB configuration issues
|
||||
*/
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { getDialogContext } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
|
||||
type Props = {
|
||||
trialRemoteSetting: ObsidianLiveSyncSettings;
|
||||
};
|
||||
const { trialRemoteSetting }: Props = $props();
|
||||
const context = getDialogContext();
|
||||
let detectedIssues = $state<ConfigCheckResult[]>([]);
|
||||
async function testAndFixSettings() {
|
||||
detectedIssues = [];
|
||||
try {
|
||||
const fixResults = await checkConfig(trialRemoteSetting);
|
||||
console.dir(fixResults);
|
||||
detectedIssues = fixResults;
|
||||
} catch (e) {
|
||||
console.error("Error during testAndFixSettings:", e);
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-check");
|
||||
detectedIssues.push({ message: `Error during testAndFixSettings: ${e}`, result: "error", classes: [] });
|
||||
}
|
||||
}
|
||||
@@ -33,14 +37,23 @@
|
||||
}
|
||||
let processing = $state(false);
|
||||
async function fixIssue(issue: ResultError<unknown>) {
|
||||
const confirmation = getCouchDBServerFixConfirmation(issue.settingKey, issue.expectedValue);
|
||||
const confirmed = await context.services.confirm.askYesNoDialog(confirmation.message, {
|
||||
title: confirmation.title,
|
||||
defaultOption: "No",
|
||||
});
|
||||
if (confirmed !== "yes") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
processing = true;
|
||||
await issue.fix();
|
||||
} catch (e) {
|
||||
console.error("Error during fixIssue:", e);
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix");
|
||||
} finally {
|
||||
await testAndFixSettings();
|
||||
processing = false;
|
||||
}
|
||||
await testAndFixSettings();
|
||||
processing = false;
|
||||
}
|
||||
const errorIssueCount = $derived.by(() => {
|
||||
return detectedIssues.filter((issue) => isErrorResult(issue)).length;
|
||||
@@ -64,7 +77,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
<UserDecisions>
|
||||
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} />
|
||||
<Decision title={translateMessage("Check server requirements")} important={true} commit={testAndFixSettings} />
|
||||
</UserDecisions>
|
||||
<div class="check-results">
|
||||
<details open={!isAllSuccess}>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import Check from "@lib/UI/components/Check.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
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 msg } from "@/common/translation";
|
||||
import {
|
||||
TYPE_CANCEL,
|
||||
TYPE_BACKUP_DONE,
|
||||
@@ -21,20 +22,19 @@
|
||||
|
||||
type Props = {
|
||||
setResult: (result: RebuildEverythingResult) => void;
|
||||
getInitialData?: () => { isP2P?: boolean } | undefined;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const isP2P = $derived(getInitialData?.()?.isP2P === true);
|
||||
|
||||
let backupType = $state<ResultTypeBackup>(TYPE_CANCEL);
|
||||
let confirmationCheck1 = $state(false);
|
||||
let confirmationCheck2 = $state(false);
|
||||
let confirmationCheck3 = $state(false);
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
(backupType === TYPE_BACKUP_DONE || backupType === TYPE_BACKUP_SKIPPED) &&
|
||||
confirmationCheck1 &&
|
||||
confirmationCheck2 &&
|
||||
confirmationCheck3
|
||||
);
|
||||
const backupConfirmed = backupType === TYPE_BACKUP_DONE || backupType === TYPE_BACKUP_SKIPPED;
|
||||
if (isP2P) return backupConfirmed && confirmationCheck1;
|
||||
return backupConfirmed && confirmationCheck1 && confirmationCheck2 && confirmationCheck3;
|
||||
});
|
||||
let preventFetchingConfig = $state(false);
|
||||
|
||||
@@ -48,33 +48,44 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title="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
|
||||
>
|
||||
<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.
|
||||
</InfoNote>
|
||||
<Guidance important title="⚠️ Please Confirm the Following">
|
||||
<Check
|
||||
title="I understand that all changes made on other smartphones or computers possibly could be lost."
|
||||
bind:value={confirmationCheck1}
|
||||
{#if isP2P}
|
||||
<DialogHeader title={msg("Ui.SetupWizard.RebuildEverythingP2P.Title")} />
|
||||
<Guidance>{msg("Ui.SetupWizard.RebuildEverythingP2P.Guidance")}</Guidance>
|
||||
<InfoNote>{msg("Ui.SetupWizard.RebuildEverythingP2P.Note")}</InfoNote>
|
||||
<Guidance important title={msg("Ui.SetupWizard.RebuildEverythingP2P.ConfirmTitle")}>
|
||||
<Check title={msg("Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalReset")} bind:value={confirmationCheck1}>
|
||||
<InfoNote>{msg("Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalResetNote")}</InfoNote>
|
||||
</Check>
|
||||
</Guidance>
|
||||
{:else}
|
||||
<DialogHeader title="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
|
||||
>
|
||||
<InfoNote>There is a way to resolve this on other devices.</InfoNote>
|
||||
<InfoNote>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."
|
||||
bind:value={confirmationCheck2}
|
||||
>
|
||||
<InfoNote>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} />
|
||||
</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.
|
||||
</InfoNote>
|
||||
<Guidance important title="⚠️ Please Confirm the Following">
|
||||
<Check
|
||||
title="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>
|
||||
</Check>
|
||||
<Check
|
||||
title="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>
|
||||
</Check>
|
||||
<Check title="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>
|
||||
@@ -103,12 +114,19 @@
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<Instruction>
|
||||
<ExtraItems title="Advanced">
|
||||
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
{#if !isP2P}
|
||||
<Instruction>
|
||||
<ExtraItems title="Advanced">
|
||||
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
|
||||
</ExtraItems>
|
||||
</Instruction>
|
||||
{/if}
|
||||
<UserDecisions>
|
||||
<Decision title="I Understand, Overwrite Server" important disabled={!canProceed} commit={() => commit()} />
|
||||
<Decision
|
||||
title={isP2P ? msg("Ui.SetupWizard.RebuildEverythingP2P.Proceed") : "I Understand, Overwrite Server"}
|
||||
important
|
||||
disabled={!canProceed}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
|
||||
</UserDecisions>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
TYPE_USE_SETUP_URI,
|
||||
TYPE_SCAN_QR_CODE,
|
||||
@@ -24,7 +25,7 @@
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
|
||||
return "I know my server details, let me enter them";
|
||||
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.";
|
||||
} else {
|
||||
@@ -49,10 +50,10 @@
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_CONFIGURE_MANUALLY}
|
||||
title="Enter the server information manually"
|
||||
title={translateMessage("Ui.SetupWizard.SelectExisting.ManualOption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
Configure the same server information as your other devices again, manually, very advanced users only.
|
||||
{translateMessage("Ui.SetupWizard.SelectExisting.ManualOptionDesc")}
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
TYPE_USE_SETUP_URI,
|
||||
TYPE_CONFIGURE_MANUALLY,
|
||||
@@ -23,7 +24,7 @@
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
|
||||
return "I know my server details, let me enter them";
|
||||
return translateMessage("Ui.SetupWizard.SelectNew.ProceedManual");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
@@ -34,22 +35,22 @@
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Connection Method" />
|
||||
<Guidance>We will now proceed with the server configuration.</Guidance>
|
||||
<Guidance>{translateMessage("Ui.SetupWizard.SelectNew.Guidance")}</Guidance>
|
||||
<Instruction>
|
||||
<Question>How would you like to configure the connection to your server?</Question>
|
||||
<Question>{translateMessage("Ui.SetupWizard.SelectNew.Question")}</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
|
||||
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.
|
||||
{translateMessage("Ui.SetupWizard.SelectNew.SetupUriOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_CONFIGURE_MANUALLY}
|
||||
title="Enter the server information manually"
|
||||
title={translateMessage("Ui.SetupWizard.SelectNew.ManualOption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
|
||||
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
|
||||
instead of a CouchDB/S3 server — P2P requires no server setup at all.
|
||||
{translateMessage("Ui.SetupWizard.SelectNew.ManualOptionDesc")}
|
||||
{translateMessage(
|
||||
"P2P requires no central data-storage server, but it still uses a signalling relay for peer discovery."
|
||||
)}
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Options from "@/modules/services/LiveSyncUI/components/Options.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
TYPE_COUCHDB,
|
||||
TYPE_BUCKET,
|
||||
@@ -23,9 +24,9 @@
|
||||
if (userType === TYPE_COUCHDB) {
|
||||
return "Continue to CouchDB setup";
|
||||
} else if (userType === TYPE_BUCKET) {
|
||||
return "Continue to S3/MinIO/R2 setup";
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
|
||||
} else if (userType === TYPE_P2P) {
|
||||
return "Continue to Peer-to-Peer only setup";
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
|
||||
} else {
|
||||
return "Please select an option to proceed";
|
||||
}
|
||||
@@ -35,21 +36,29 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Enter Server Information" />
|
||||
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
|
||||
<Instruction>
|
||||
<Question>Please select the type of server to which you are connecting.</Question>
|
||||
<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.
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_BUCKET} title="S3/MinIO/R2 Object Storage" bind:value={userType}>
|
||||
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
|
||||
<Option
|
||||
selectedValue={TYPE_BUCKET}
|
||||
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
|
||||
</Option>
|
||||
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
|
||||
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.
|
||||
<Option
|
||||
selectedValue={TYPE_P2P}
|
||||
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage(
|
||||
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
||||
)}
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type ObsidianLiveSyncSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
RemoteTypes,
|
||||
} from "@lib/common/types";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { copyTo, pickBucketSyncSettings } from "@lib/common/utils";
|
||||
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";
|
||||
|
||||
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_SETTING_CLOUDANT,
|
||||
@@ -14,25 +14,34 @@
|
||||
RemoteTypes,
|
||||
type CouchDBConnection,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@lib/common/types";
|
||||
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isCloudantURI } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { copyTo, pickCouchDBSyncSettings } from "@lib/common/utils";
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { copyTo, pickCouchDBSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
|
||||
import { TYPE_CANCELLED, type SetupRemoteCouchDBResultType } from "./setupDialogTypes";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type CouchDBSetupMode,
|
||||
type SetupRemoteCouchDBInitialData,
|
||||
type SetupRemoteCouchDBResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
|
||||
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, CouchDBConnection>;
|
||||
let setupMode = $state<CouchDBSetupMode>("settings");
|
||||
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, SetupRemoteCouchDBInitialData>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
setupMode = initialData.mode;
|
||||
copyTo(initialData.settings, syncSetting);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -69,11 +78,15 @@
|
||||
return "Failed to create replicator instance.";
|
||||
}
|
||||
try {
|
||||
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
|
||||
if (result) {
|
||||
const result = await probeCouchDBConnection(
|
||||
replicator,
|
||||
trialRemoteSetting,
|
||||
setupMode === "create-or-connect"
|
||||
);
|
||||
if (result.ok) {
|
||||
return "";
|
||||
} else {
|
||||
return "Failed to connect to the server. Please check your settings.";
|
||||
return `Failed to connect to the server: ${result.reason}`;
|
||||
}
|
||||
} catch (e) {
|
||||
return `Failed to connect to the server: ${e}`;
|
||||
@@ -122,7 +135,7 @@
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return (
|
||||
syncSetting.couchDB_URI.trim().length > 0 &&
|
||||
isValidCouchDBServerURL(syncSetting.couchDB_URI.trim()) &&
|
||||
syncSetting.couchDB_USER.trim().length > 0 &&
|
||||
syncSetting.couchDB_PASSWORD.trim().length > 0 &&
|
||||
syncSetting.couchDB_DBNAME.trim().length > 0 &&
|
||||
@@ -132,6 +145,18 @@
|
||||
const testSettings = $derived.by(() => {
|
||||
return generateSetting();
|
||||
});
|
||||
const isURLInvalid = $derived.by(
|
||||
() => syncSetting.couchDB_URI.trim() !== "" && !isValidCouchDBServerURL(syncSetting.couchDB_URI.trim())
|
||||
);
|
||||
const primaryActionTitle = $derived.by(() => {
|
||||
if (setupMode === "create-or-connect") {
|
||||
return translateMessage("Create or connect to database and continue");
|
||||
}
|
||||
if (setupMode === "connect-existing") {
|
||||
return translateMessage("Connect to existing database and continue");
|
||||
}
|
||||
return translateMessage("Test connection and save");
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title="CouchDB Configuration" />
|
||||
@@ -150,6 +175,7 @@
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isURIInsecure}>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">
|
||||
<input
|
||||
type="text"
|
||||
@@ -180,13 +206,11 @@
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
pattern="^[a-z][a-z0-9_$()+/-]*$"
|
||||
bind:value={syncSetting.couchDB_DBNAME}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an
|
||||
underscore (_).
|
||||
{translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")}
|
||||
</InfoNote>
|
||||
<InputRow label="Use Internal API">
|
||||
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
|
||||
@@ -270,6 +294,11 @@
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required."
|
||||
)}
|
||||
</InfoNote>
|
||||
<PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
|
||||
<hr />
|
||||
|
||||
@@ -281,8 +310,19 @@
|
||||
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={primaryActionTitle} important disabled={!canProceed} commit={() => checkAndCommit()} />
|
||||
{#if setupMode === "settings"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Decision
|
||||
title={translateMessage("Save without connecting")}
|
||||
disabled={!canProceed}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
{/if}
|
||||
<Decision title="Cancel" commit={() => cancel()} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
E2EEAlgorithmNames,
|
||||
E2EEAlgorithms,
|
||||
type EncryptionSettings,
|
||||
} from "@lib/common/types";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "@lib/common/utils";
|
||||
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";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
// import { delay } from "octagonal-wheels/promises";
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import { PouchDB } from "@lib/pouchdb/pouchdb-browser";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
@@ -17,16 +17,24 @@
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PConnectionInfo,
|
||||
type P2PSyncSetting,
|
||||
} from "@lib/common/types";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
import { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "@lib/replication/trystero/types";
|
||||
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "@lib/common/utils";
|
||||
import { TrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/types";
|
||||
import {
|
||||
copyTo,
|
||||
generateP2PRoomId,
|
||||
pickP2PSyncSettings,
|
||||
type SimpleStore,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
|
||||
|
||||
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
|
||||
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
|
||||
@@ -99,6 +107,8 @@
|
||||
|
||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||
const env: ReplicatorHostEnv = {
|
||||
events: context.context.events,
|
||||
translate: context.context.translate,
|
||||
settings: trialRemoteSetting,
|
||||
processReplicatedDocs: async (_docs: any[]) => {
|
||||
return;
|
||||
@@ -111,31 +121,17 @@
|
||||
};
|
||||
const replicator = new TrysteroReplicator(env);
|
||||
try {
|
||||
await replicator.setOnSetup();
|
||||
await replicator.allowReconnection();
|
||||
await replicator.open();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// await delay(1000);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
||||
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
|
||||
if (replicator.knownAdvertisements.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// context.holdingSettings = trialRemoteSetting;
|
||||
|
||||
if (replicator.knownAdvertisements.length === 0) {
|
||||
return "Your settings seem correct, but no other peers were found.";
|
||||
const result = await probeP2PSetupConnection(replicator);
|
||||
if (!result.ok) {
|
||||
return `Failed to connect to the signalling relay: ${result.reason}`;
|
||||
}
|
||||
return "";
|
||||
} catch (e) {
|
||||
return `Failed to connect to other peers: ${e}`;
|
||||
} finally {
|
||||
try {
|
||||
replicator.close();
|
||||
dummyPouch.destroy();
|
||||
await replicator.close();
|
||||
await dummyPouch.destroy();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Logger(e, LOG_LEVEL_VERBOSE, "setup-p2p-cleanup");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -148,17 +144,7 @@
|
||||
|
||||
let processing = $state(false);
|
||||
function generateDefaultGroupId() {
|
||||
const randomValues = new Uint16Array(4);
|
||||
crypto.getRandomValues(randomValues);
|
||||
const MAX_UINT16 = 65536;
|
||||
const a = Math.floor((randomValues[0] / MAX_UINT16) * 1000);
|
||||
const b = Math.floor((randomValues[1] / MAX_UINT16) * 1000);
|
||||
const c = Math.floor((randomValues[2] / MAX_UINT16) * 1000);
|
||||
const d_range = 36 * 36 * 36;
|
||||
const d = Math.floor((randomValues[3] / MAX_UINT16) * d_range);
|
||||
syncSetting.P2P_roomID = `${a.toString().padStart(3, "0")}-${b
|
||||
.toString()
|
||||
.padStart(3, "0")}-${c.toString().padStart(3, "0")}-${d.toString(36).padStart(3, "0")}`;
|
||||
syncSetting.P2P_roomID = generateP2PRoomId();
|
||||
}
|
||||
|
||||
async function checkAndCommit() {
|
||||
@@ -197,18 +183,31 @@
|
||||
<InputRow label="Enabled">
|
||||
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
|
||||
</InputRow>
|
||||
<InputRow label="Relay URL">
|
||||
<InputRow label={translateMessage("Signalling relay URLs")}>
|
||||
<input
|
||||
type="text"
|
||||
name="p2p-relay-url"
|
||||
placeholder="Enter the Relay URL)"
|
||||
placeholder="wss://relay.example.com"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.P2P_relays}
|
||||
/>
|
||||
<button class="button" onclick={() => setDefaultRelay()}>Use vrtmrz's relay</button>
|
||||
<button class="button" onclick={() => setDefaultRelay()}>
|
||||
{translateMessage("Use the project's public signalling relay")}
|
||||
</button>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage("Peer discovery uses Nostr-compatible signalling relays.")}
|
||||
{translateMessage(
|
||||
"The project's public signalling relay is a best-effort convenience operated by the project author. It does not store Vault contents, but signalling metadata may be visible to the relay. Availability and log retention are not guaranteed. You can replace it with your own Nostr-compatible relay."
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{translateMessage("Learn more about P2P connections")}</a
|
||||
>.
|
||||
</InfoNote>
|
||||
<InputRow label="Group ID">
|
||||
<input
|
||||
type="text"
|
||||
@@ -247,12 +246,13 @@
|
||||
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
|
||||
launches.
|
||||
</InfoNote>
|
||||
<InputRow label="Auto Broadcast Changes">
|
||||
<InputRow label={translateMessage("Announce changes automatically after connecting")}>
|
||||
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without
|
||||
requiring manual intervention. This requests peers to fetch this device's changes.
|
||||
{translateMessage(
|
||||
"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">
|
||||
<InfoNote>
|
||||
@@ -260,10 +260,14 @@
|
||||
connections. In most cases, you can leave these fields blank.
|
||||
</InfoNote>
|
||||
<InfoNote warning>
|
||||
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
|
||||
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
|
||||
server provider before using their services. Also your `network administrator` too. You should consider setting
|
||||
up your own TURN server for your FQDN, if possible.
|
||||
{translateMessage(
|
||||
"TURN relays the encrypted WebRTC connection only when a direct path cannot be established. A TURN provider cannot read encrypted Vault contents, but it can observe connection metadata and traffic volume. Use a provider you trust."
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/p2p.md#signalling-relay-and-turn-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{translateMessage("Learn more about signalling and TURN")}</a
|
||||
>.
|
||||
</InfoNote>
|
||||
<InputRow label="TURN Server URLs (comma-separated)">
|
||||
<textarea
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { configURIBase } from "@/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { decryptString } from "@lib/encryption/stringEncryption.ts";
|
||||
import type { GuestDialogProps } from "@lib/UI/svelteDialog.ts";
|
||||
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
|
||||
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { TYPE_CANCELLED, type UseSetupURIResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = GuestDialogProps<UseSetupURIResultType, string>;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
ObsidianLiveSyncSettings,
|
||||
RemoteDBSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
|
||||
export type CouchDBConnectionProbeResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
type CouchDBConnectionResult =
|
||||
| string
|
||||
| {
|
||||
db: unknown;
|
||||
info: unknown;
|
||||
};
|
||||
|
||||
export interface CouchDBConnectionProbe {
|
||||
isMobile(): boolean;
|
||||
connectRemoteCouchDBWithSetting(
|
||||
settings: RemoteDBSettings,
|
||||
isMobile: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean
|
||||
): CouchDBConnectionResult | Promise<CouchDBConnectionResult>;
|
||||
}
|
||||
|
||||
export function isCouchDBConnectionProbe(value: unknown): value is CouchDBConnectionProbe {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"isMobile" in value &&
|
||||
typeof value.isMobile === "function" &&
|
||||
"connectRemoteCouchDBWithSetting" in value &&
|
||||
typeof value.connectRemoteCouchDBWithSetting === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export async function probeCouchDBConnection(
|
||||
replicator: unknown,
|
||||
settings: ObsidianLiveSyncSettings,
|
||||
createIfMissing: boolean
|
||||
): Promise<CouchDBConnectionProbeResult> {
|
||||
if (!isCouchDBConnectionProbe(replicator)) {
|
||||
return { ok: false, reason: "The CouchDB connection probe is unavailable." };
|
||||
}
|
||||
const result = await replicator.connectRemoteCouchDBWithSetting(
|
||||
settings,
|
||||
replicator.isMobile(),
|
||||
createIfMissing,
|
||||
false
|
||||
);
|
||||
if (typeof result === "string") {
|
||||
return { ok: false, reason: result };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function isValidCouchDBServerURL(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import { isValidCouchDBServerURL, probeCouchDBConnection } from "./couchDBConnectionProbe";
|
||||
|
||||
const settings = {
|
||||
couchDB_URI: "https://couch.example",
|
||||
couchDB_DBNAME: "notes",
|
||||
} as ObsidianLiveSyncSettings;
|
||||
|
||||
describe("CouchDB setup connection policy", () => {
|
||||
it.each([
|
||||
[false, "connect to an existing database"],
|
||||
[true, "create or connect to a database"],
|
||||
] as const)(
|
||||
"%s can %s without changing the Commonlib connection contract",
|
||||
async (createIfMissing, _description) => {
|
||||
const connectRemoteCouchDBWithSetting = vi.fn(async () => ({
|
||||
db: {},
|
||||
info: { db_name: "notes" },
|
||||
}));
|
||||
const replicator = {
|
||||
isMobile: vi.fn(() => false),
|
||||
connectRemoteCouchDBWithSetting,
|
||||
tryConnectRemote: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(probeCouchDBConnection(replicator, settings, createIfMissing)).resolves.toEqual({ ok: true });
|
||||
expect(connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(settings, false, createIfMissing, false);
|
||||
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("returns the connection error without saving or creating through another path", async () => {
|
||||
const replicator = {
|
||||
isMobile: vi.fn(() => true),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(() => "database does not exist"),
|
||||
};
|
||||
|
||||
await expect(probeCouchDBConnection(replicator, settings, false)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "database does not exist",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://couch.example", true],
|
||||
["http://127.0.0.1:5984", true],
|
||||
["ftp://couch.example", false],
|
||||
["couch.example", false],
|
||||
["https://", false],
|
||||
])("validates the saved server URL %s", (value, expected) => {
|
||||
expect(isValidCouchDBServerURL(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
export function getCouchDBServerFixConfirmation(settingKey: string, expectedValue: string) {
|
||||
return {
|
||||
title: $msg("Change CouchDB server setting"),
|
||||
message: $msg("Change CouchDB server setting '${SETTING}' to '${VALUE}'?", {
|
||||
SETTING: settingKey,
|
||||
VALUE: expectedValue,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation";
|
||||
|
||||
describe("CouchDB server requirement fixes", () => {
|
||||
it("identifies the exact server setting and value before a fix is applied", () => {
|
||||
expect(getCouchDBServerFixConfirmation("chttpd/require_valid_user", "true")).toEqual({
|
||||
title: "Change CouchDB server setting",
|
||||
message: "Change CouchDB server setting 'chttpd/require_valid_user' to 'true'?",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export type P2PSetupConnectionProbeResult = { ok: true } | { ok: false; reason: string };
|
||||
|
||||
export interface P2PSetupConnectionProbe {
|
||||
setOnSetup(): void | Promise<void>;
|
||||
allowReconnection(): void | Promise<void>;
|
||||
open(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function probeP2PSetupConnection(
|
||||
replicator: P2PSetupConnectionProbe
|
||||
): Promise<P2PSetupConnectionProbeResult> {
|
||||
try {
|
||||
await replicator.setOnSetup();
|
||||
await replicator.allowReconnection();
|
||||
await replicator.open();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { probeP2PSetupConnection } from "./p2pSetupConnectionProbe";
|
||||
|
||||
describe("P2P setup connection probe", () => {
|
||||
it("accepts an empty room after the signalling connection opens", async () => {
|
||||
const replicator = {
|
||||
knownAdvertisements: [],
|
||||
setOnSetup: vi.fn(),
|
||||
allowReconnection: vi.fn(),
|
||||
open: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({ ok: true });
|
||||
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
|
||||
expect(replicator.allowReconnection).toHaveBeenCalledOnce();
|
||||
expect(replicator.open).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a signalling connection failure", async () => {
|
||||
const replicator = {
|
||||
knownAdvertisements: [],
|
||||
setOnSetup: vi.fn(),
|
||||
allowReconnection: vi.fn(),
|
||||
open: vi.fn(async () => {
|
||||
throw new Error("relay unavailable");
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(probeP2PSetupConnection(replicator)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "relay unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
EncryptionSettings,
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
} from "@lib/common/models/setting.type";
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
|
||||
export const TYPE_IDENTICAL = "identical";
|
||||
export const TYPE_INDEPENDENT = "independent";
|
||||
@@ -102,6 +102,11 @@ export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettin
|
||||
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
|
||||
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
|
||||
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
|
||||
export type SetupRemoteCouchDBInitialData = {
|
||||
settings: CouchDBConnection;
|
||||
mode: CouchDBSetupMode;
|
||||
};
|
||||
|
||||
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { requestToCouchDBWithCredentials } from "@/common/utils";
|
||||
import { $msg } from "@lib/common/i18n";
|
||||
import { Logger } from "@lib/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { parseHeaderValues } from "@lib/common/utils";
|
||||
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
|
||||
import { generateCredentialObject } from "@lib/replication/httplib";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { isUnauthorizedError } from "@lib/common/utils.doc";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseHeaderValues } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isCloudantURI } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb";
|
||||
import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/replication/httplib";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||
|
||||
export type ResultMessage = { message: string; classes: string[] };
|
||||
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
||||
export type ResultOk<T> = { message: string; result: "ok"; value?: T };
|
||||
export type ResultError<T> = { message: string; result: "error"; value: T; fixMessage: string; fix(): Promise<void> };
|
||||
export type ResultError<T> = {
|
||||
message: string;
|
||||
result: "error";
|
||||
value: T;
|
||||
fixMessage: string;
|
||||
settingKey: string;
|
||||
expectedValue: string;
|
||||
fix(): Promise<void>;
|
||||
};
|
||||
export type ConfigCheckResult<T = unknown, U = unknown> =
|
||||
| ResultOk<T>
|
||||
| ResultError<U>
|
||||
@@ -78,8 +87,15 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
const addSuccess = <T>(msg: string, value?: T) => {
|
||||
result.push({ message: msg, result: "ok", value });
|
||||
};
|
||||
const _addError = <T>(message: string, fixMessage: string, fix: () => Promise<void>, value?: T) => {
|
||||
result.push({ message, result: "error", fixMessage, fix, value });
|
||||
const _addError = <T>(
|
||||
message: string,
|
||||
fixMessage: string,
|
||||
settingKey: string,
|
||||
expectedValue: string,
|
||||
fix: () => Promise<void>,
|
||||
value?: T
|
||||
) => {
|
||||
result.push({ message, result: "error", fixMessage, settingKey, expectedValue, fix, value });
|
||||
};
|
||||
const addErrorMessage = (msg: string, classes: string[] = []) => {
|
||||
result.push({ message: msg, result: "error", classes });
|
||||
@@ -89,6 +105,8 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
_addError(
|
||||
message,
|
||||
fixMessage,
|
||||
key,
|
||||
expected,
|
||||
async () => {
|
||||
await updateRemoteSetting(editingSettings, key, expected);
|
||||
},
|
||||
@@ -115,7 +133,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
const responseConfig = r.json;
|
||||
const responseConfig = normaliseCouchDBConfiguration(r.json as unknown);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgNotice"), ["ob-btn-config-head"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]);
|
||||
addMessage($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
const STATUS_COUNTER_PADDING = "\u2007".repeat(10);
|
||||
|
||||
export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000;
|
||||
|
||||
export type DisposableReactiveValue<T> = ReactiveValue<T> & {
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
function asDisposableReactiveValue<T>(value: ReactiveSource<T>, dispose: () => void): DisposableReactiveValue<T> {
|
||||
return {
|
||||
get value() {
|
||||
return value.value;
|
||||
},
|
||||
onChanged(handler) {
|
||||
value.onChanged(handler);
|
||||
},
|
||||
offChanged(handler) {
|
||||
value.offChanged(handler);
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors an activity count while keeping each visible period on screen for a
|
||||
* minimum total lifetime. The delay applies only when the source becomes zero.
|
||||
*/
|
||||
export function createMinimumVisibleActivityCount(
|
||||
source: ReactiveValue<number>,
|
||||
minimumVisibleMs: number
|
||||
): DisposableReactiveValue<number> {
|
||||
const minimumLifetime = Math.max(0, minimumVisibleMs);
|
||||
const displayed = reactiveSource(Math.max(0, source.value));
|
||||
let visibleSince = displayed.value > 0 ? Date.now() : undefined;
|
||||
let hideTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelHide = () => {
|
||||
if (hideTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(hideTimer);
|
||||
hideTimer = undefined;
|
||||
};
|
||||
const hideIfIdle = () => {
|
||||
hideTimer = undefined;
|
||||
if (disposed || Math.max(0, source.value) > 0) return;
|
||||
displayed.value = 0;
|
||||
visibleSince = undefined;
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const nextCount = Math.max(0, source.value);
|
||||
cancelHide();
|
||||
if (nextCount > 0) {
|
||||
if (displayed.value === 0) {
|
||||
visibleSince = Date.now();
|
||||
}
|
||||
displayed.value = nextCount;
|
||||
return;
|
||||
}
|
||||
if (displayed.value === 0) {
|
||||
visibleSince = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - (visibleSince ?? Date.now());
|
||||
const remaining = Math.max(0, minimumLifetime - elapsed);
|
||||
if (remaining === 0) {
|
||||
hideIfIdle();
|
||||
} else {
|
||||
hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining);
|
||||
}
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
return asDisposableReactiveValue(displayed, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelHide();
|
||||
source.offChanged(update);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a counter with a stable width and briefly retains its zero value so
|
||||
* that the completion of queued work remains visible.
|
||||
*/
|
||||
export function createPaddedCounterLabel(
|
||||
source: ReactiveValue<number>,
|
||||
mark: string,
|
||||
inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS
|
||||
): DisposableReactiveValue<string> {
|
||||
const linger = Math.max(0, inactiveLingerMs);
|
||||
const formatted = reactiveSource("");
|
||||
let maximumLength = 1;
|
||||
let clearTimer: number | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const cancelClear = () => {
|
||||
if (clearTimer === undefined) return;
|
||||
compatGlobal.clearTimeout(clearTimer);
|
||||
clearTimer = undefined;
|
||||
};
|
||||
const format = (count: number) => {
|
||||
const requiredLength = `${Math.abs(count)}`.length + 1;
|
||||
maximumLength = Math.max(maximumLength, requiredLength);
|
||||
return ` ${mark}${`${STATUS_COUNTER_PADDING}${count}`.slice(-maximumLength)}`;
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
cancelClear();
|
||||
const count = source.value;
|
||||
formatted.value = format(count);
|
||||
if (count !== 0) return;
|
||||
clearTimer = compatGlobal.setTimeout(() => {
|
||||
clearTimer = undefined;
|
||||
if (disposed) return;
|
||||
formatted.value = "";
|
||||
maximumLength = 1;
|
||||
}, linger);
|
||||
};
|
||||
|
||||
source.onChanged(update);
|
||||
return asDisposableReactiveValue(formatted, () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
cancelClear();
|
||||
source.offChanged(update);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
STATUS_COUNTER_INACTIVE_LINGER_MS,
|
||||
createMinimumVisibleActivityCount,
|
||||
createPaddedCounterLabel,
|
||||
} from "./StatusBarDisplay.ts";
|
||||
|
||||
describe("createMinimumVisibleActivityCount", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-07-16T00:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps a short activity visible for the configured minimum lifetime", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
const rendered = reactive(() => `active:${display.value}`);
|
||||
|
||||
expect(rendered.value).toBe("active:0");
|
||||
source.value = 1;
|
||||
expect(rendered.value).toBe("active:1");
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = 0;
|
||||
|
||||
expect(display.value).toBe(1);
|
||||
vi.advanceTimersByTime(99);
|
||||
expect(display.value).toBe(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBe(0);
|
||||
expect(rendered.value).toBe("active:0");
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("updates overlapping activity and starts a new minimum lifetime after becoming idle", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
|
||||
source.value = 1;
|
||||
vi.advanceTimersByTime(25);
|
||||
source.value = 2;
|
||||
expect(display.value).toBe(2);
|
||||
source.value = 0;
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
source.value = 1;
|
||||
expect(display.value).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(75);
|
||||
source.value = 0;
|
||||
expect(display.value).toBe(0);
|
||||
|
||||
source.value = 3;
|
||||
source.value = 0;
|
||||
expect(display.value).toBe(3);
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(display.value).toBe(0);
|
||||
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels pending work and stops observing its source when disposed", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createMinimumVisibleActivityCount(source, 150);
|
||||
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(150);
|
||||
source.value = 2;
|
||||
|
||||
expect(display.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPaddedCounterLabel", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps the widest counter label until its inactive linger period ends", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📥");
|
||||
|
||||
expect(display.value).toBe("");
|
||||
source.value = 9;
|
||||
expect(display.value).toBe(" 📥\u20079");
|
||||
source.value = 123;
|
||||
expect(display.value).toBe(" 📥\u2007123");
|
||||
source.value = 0;
|
||||
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
|
||||
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1);
|
||||
expect(display.value).toBe(" 📥\u2007\u2007\u20070");
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(display.value).toBe("");
|
||||
|
||||
source.value = 7;
|
||||
expect(display.value).toBe(" 📥\u20077");
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels the pending clear when counter activity resumes", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
source.value = 1;
|
||||
source.value = 0;
|
||||
vi.advanceTimersByTime(1_000);
|
||||
source.value = 2;
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
|
||||
expect(display.value).toBe(" 📄\u20072");
|
||||
display.dispose();
|
||||
});
|
||||
|
||||
it("cancels its inactive timer and source subscription when disposed", () => {
|
||||
const source = reactiveSource(0);
|
||||
const display = createPaddedCounterLabel(source, "📄");
|
||||
|
||||
source.value = 4;
|
||||
source.value = 0;
|
||||
display.dispose();
|
||||
vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS);
|
||||
source.value = 5;
|
||||
|
||||
expect(display.value).toBe(" 📄\u20070");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user