Prepare the 1.0 compatibility review flow

This commit is contained in:
vorotamoroz
2026-07-19 04:34:08 +00:00
parent 52138bf7a5
commit ed3f81e9f9
42 changed files with 5524 additions and 4119 deletions
+35 -7
View File
@@ -1,4 +1,4 @@
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import MessageBox from "./ui/MessageBox.svelte";
import TextInputBox from "./ui/TextInputBox.svelte";
@@ -6,13 +6,14 @@ import TextInputBox from "./ui/TextInputBox.svelte";
import { mount } from "svelte";
import { promiseWithResolvers } from "octagonal-wheels/promises";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { _activeDocument } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { _activeDocument, compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
function displayMessageBox<T, U extends string[]>(
message: string,
buttons: U,
title: string,
commit: (ret: U[number]) => T
commit: (ret: U[number]) => T,
actionLayout?: ConfirmActionLayout
): Promise<T> {
const el = _activeDocument.createElement("div");
const p = promiseWithResolvers<T>();
@@ -22,6 +23,7 @@ function displayMessageBox<T, U extends string[]>(
message,
buttons: buttons as string[],
title: title,
actionLayout,
commit: (action: U[number]) => {
const ret = commit(action);
p.resolve(ret);
@@ -92,16 +94,42 @@ export class BrowserConfirm<T extends ServiceContext> implements Confirm {
): Promise<T[number] | false> {
return displayMessageBox(message, [...buttons] as const, opt.title ?? "Confirm", (action) => action);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void): void {
throw new Error("Method not implemented.");
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
): void {
const existing = _activeDocument.querySelector(`[data-livesync-popup="${CSS.escape(key)}"]`);
existing?.remove();
const notice = _activeDocument.createElement("div");
notice.className = "livesync-browser-notice";
notice.dataset.livesyncPopup = key;
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
notice.append(beforeText);
const anchor = _activeDocument.createElement("a");
anchor.href = "#";
anchorCallback(anchor);
anchor.addEventListener("click", () => notice.remove());
notice.append(anchor, afterText ?? "");
_activeDocument.body.appendChild(notice);
compatGlobal.setTimeout(() => notice.remove(), durationMs);
}
confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return displayMessageBox(contentMd, [...buttons] as const, title ?? "Confirm", (action) => action);
return displayMessageBox(
contentMd,
[...buttons] as const,
title ?? "Confirm",
(action) => action,
actionLayout
);
}
}
+12 -2
View File
@@ -5,9 +5,11 @@
title: string;
message: string;
buttons: string[];
actionLayout?: ConfirmActionLayout;
commit: (button: string) => void;
};
let { title, message, buttons, commit }: Props = $props();
type ConfirmActionLayout = "auto" | "vertical";
let { title, message, buttons, actionLayout, commit }: Props = $props();
const renderedMessage = $derived(renderMessageMarkdown(message));
function handleEsc(event: KeyboardEvent) {
@@ -20,7 +22,7 @@
<popup>
<header>{title}</header>
<article><div class="msg">{@html renderedMessage}</div></article>
<div class="buttons">
<div class:vertical={actionLayout === "vertical"} class="buttons">
{#each buttons as button}
<button onclick={() => commit(button)}>{button}</button>
{/each}
@@ -116,6 +118,14 @@
margin: 0 0.5em;
background-color: var(--background-primary-alt);
}
popup .buttons.vertical {
align-items: stretch;
flex-direction: column;
}
popup .buttons.vertical button {
margin: 0.25em 0;
width: 100%;
}
popup ~ .background {
position: fixed;
top: 0;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "0.25.83-cli",
"version": "1.0.0-rc.0-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "0.25.83-webapp",
"version": "1.0.0-rc.0-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "0.25.83-webpeer",
"version": "1.0.0-rc.0-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+144
View File
@@ -0,0 +1,144 @@
import type { SettingsMigrationState } from "@vrtmrz/livesync-commonlib/compat/common/types";
export const DATABASE_COMPATIBILITY_VERSION_KEY = "database-compatibility-version";
export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-sync-ver";
export const COMPATIBILITY_PAUSE_SETTING_MESSAGE =
"Remote synchronisation is paused until this device's compatibility review has been completed.";
export type DatabaseCompatibilityVersionState = "missing" | "invalid" | "upgrade" | "downgrade";
export interface DatabaseCompatibilityReason {
source: "database-version";
state: DatabaseCompatibilityVersionState;
acknowledgedVersion?: number;
currentVersion: number;
resumable: boolean;
}
export interface SettingsCompatibilityReason {
source: "settings-schema";
sourceVersion: number;
currentVersion: number;
isFromFutureSchema: boolean;
resumable: boolean;
}
export interface LegacyCompatibilityReason {
source: "legacy-review";
message: string;
resumable: true;
}
export type CompatibilityPauseReason =
| DatabaseCompatibilityReason
| SettingsCompatibilityReason
| LegacyCompatibilityReason;
export interface CompatibilityPause {
reasons: readonly CompatibilityPauseReason[];
resumable: boolean;
}
export interface CompatibilityEvaluation {
pause?: CompatibilityPause;
initialiseAcknowledgedVersion: boolean;
}
export interface CompatibilityEvaluationInput {
acknowledgedVersion: string | null;
currentVersion: number;
migrationState?: SettingsMigrationState;
legacyReviewMessage: string;
}
function databaseVersionReason(
acknowledgedVersion: string | null,
currentVersion: number,
isNewVault: boolean
): DatabaseCompatibilityReason | undefined {
if (acknowledgedVersion === null || acknowledgedVersion === "") {
if (isNewVault) return undefined;
return {
source: "database-version",
state: "missing",
currentVersion,
resumable: true,
};
}
const parsed = Number(acknowledgedVersion);
if (!Number.isSafeInteger(parsed)) {
return {
source: "database-version",
state: "invalid",
currentVersion,
resumable: true,
};
}
if (parsed === currentVersion) return undefined;
if (parsed < currentVersion) {
return {
source: "database-version",
state: "upgrade",
acknowledgedVersion: parsed,
currentVersion,
resumable: true,
};
}
return {
source: "database-version",
state: "downgrade",
acknowledgedVersion: parsed,
currentVersion,
resumable: false,
};
}
/**
* Derives a host-neutral compatibility pause from device-local and settings-schema state.
*
* The caller owns persistence, user interaction, and the actual replication gate.
*/
export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput): CompatibilityEvaluation {
const isNewVault = input.migrationState?.isNewVault === true;
const reasons: CompatibilityPauseReason[] = [];
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion, isNewVault);
if (databaseReason) reasons.push(databaseReason);
if (input.migrationState?.requiresSyncReview === true) {
reasons.push({
source: "settings-schema",
sourceVersion: input.migrationState.sourceVersion,
currentVersion: input.migrationState.targetVersion,
isFromFutureSchema: input.migrationState.isFromFutureSchema,
resumable: !input.migrationState.isFromFutureSchema,
});
}
if (input.legacyReviewMessage !== "" && reasons.length === 0) {
reasons.push({
source: "legacy-review",
message: input.legacyReviewMessage,
resumable: true,
});
}
if (reasons.length === 0) {
return {
initialiseAcknowledgedVersion:
isNewVault && (input.acknowledgedVersion === null || input.acknowledgedVersion === ""),
};
}
return {
pause: {
reasons,
resumable: reasons.every((reason) => reason.resumable),
},
initialiseAcknowledgedVersion: false,
};
}
export function legacyDatabaseCompatibilityVersionKey(vaultName: string): string {
return `${DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX}${vaultName}`;
}
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from "vitest";
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { evaluateCompatibilityPause, legacyDatabaseCompatibilityVersionKey } from "./databaseCompatibility.ts";
function migrationState(overrides: Record<string, unknown> = {}) {
return {
sourceVersion: 2,
targetVersion: 2,
isNewVault: false,
isFromFutureSchema: false,
changed: false,
requiresSyncReview: false,
reviewReasons: [],
...overrides,
} as never;
}
describe("database compatibility evaluation", () => {
it("initialises a new Vault without presenting an upgrade review", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: null,
currentVersion: 12,
migrationState: migrationState({ isNewVault: true }),
legacyReviewMessage: "",
});
expect(result).toEqual({ initialiseAcknowledgedVersion: true });
});
it("allows an older acknowledged version to be reviewed and resumed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "11",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "database-version",
state: "upgrade",
acknowledgedVersion: 11,
currentVersion: 12,
resumable: true,
},
],
});
expect(result.initialiseAcknowledgedVersion).toBe(false);
});
it("does not permit an older implementation to acknowledge a newer database version", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "13",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(false);
expect(result.pause?.reasons[0]).toMatchObject({
source: "database-version",
state: "downgrade",
acknowledgedVersion: 13,
currentVersion: 12,
});
});
it("requires review when an existing Vault has no valid acknowledged version", () => {
for (const acknowledgedVersion of [null, "invalid"]) {
const result = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "",
});
expect(result.pause?.resumable).toBe(true);
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version" });
}
});
it("does not permit a future settings schema to be acknowledged by an older implementation", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState({
sourceVersion: 3,
targetVersion: 2,
isFromFutureSchema: true,
requiresSyncReview: true,
}),
legacyReviewMessage: "",
});
expect(result.pause).toEqual({
resumable: false,
reasons: [
{
source: "settings-schema",
sourceVersion: 3,
currentVersion: 2,
isFromFutureSchema: true,
resumable: false,
},
],
});
});
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
const result = evaluateCompatibilityPause({
acknowledgedVersion: "12",
currentVersion: 12,
migrationState: migrationState(),
legacyReviewMessage: "Review an earlier compatibility change.",
});
expect(result.pause).toEqual({
resumable: true,
reasons: [
{
source: "legacy-review",
message: "Review an earlier compatibility change.",
resumable: true,
},
],
});
});
it("scopes the legacy marker to the Vault", () => {
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
});
});
describe("packaged Commonlib compatibility gate", () => {
it("prevents replication while the compatibility review remains pending", async () => {
const openReplication = vi.fn().mockResolvedValue(true);
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
const dependencies = {
APIService: { isOnline: true, addLog: vi.fn() },
appLifecycleService: {
isReady: () => true,
getUnresolvedMessages: Object.assign(vi.fn().mockResolvedValue([]), { addHandler: vi.fn() }),
},
databaseService: {},
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
replicatorService: {
getActiveReplicator: () => ({ openReplication }),
runFiniteReplicationActivity,
},
settingService: {
currentSettings: () => ({ versionUpFlash: "Review the database compatibility change." }),
},
};
const service = new InjectableReplicationService(new ServiceContext(), dependencies as never);
await expect(service.replicate(true)).resolves.toBe(false);
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
expect(openReplication).not.toHaveBeenCalled();
});
});
+5 -39
View File
@@ -44,7 +44,11 @@ import {
isObjectDifferent,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { digestHash } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/hash";
import { arrayBufferToBase64, decodeBinary, readString } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import {
arrayBufferToBase64,
decodeBinary,
readString,
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { serialized, shareRunningResult } from "octagonal-wheels/concurrency/lock";
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
@@ -1683,43 +1687,6 @@ export class ConfigSync extends LiveSyncCommands {
return filenames as FilePath[];
}
private async _allAskUsingOptionalSyncFeature(opt: {
enableFetch?: boolean;
enableOverwrite?: boolean;
}): Promise<boolean> {
await this.__askHiddenFileConfiguration(opt);
return true;
}
private async __askHiddenFileConfiguration(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
const message = `Would you like to enable **Customization sync**?
> [!DETAILS]-
> This feature allows you to sync your customisations -- such as configurations, themes, snippets, and plugins -- across your devices in a fully controlled manner, unlike the fully automatic behaviour of hidden file synchronisation.
>
> You may use this feature alongside hidden file synchronisation. When both features are enabled, items configured as \`Automatic\` in this feature will be managed by **hidden file synchronisation**.
> Do not worry, you will be prompted to enable or keep disabled **hidden file synchronisation** after this dialogue.
`;
const CHOICE_CUSTOMIZE = "Yes, Enable it";
const CHOICE_DISABLE = "No, Disable it";
const CHOICE_DISMISS = "Later";
const choices = [];
choices.push(CHOICE_CUSTOMIZE);
choices.push(CHOICE_DISABLE);
choices.push(CHOICE_DISMISS);
const ret = await this.core.confirm.askSelectStringDialogue(message, choices, {
defaultAction: CHOICE_DISMISS,
timeout: 40,
title: "Customisation sync",
});
if (ret == CHOICE_CUSTOMIZE) {
await this.configureHiddenFileSync("CUSTOMIZE");
} else if (ret == CHOICE_DISABLE) {
await this.configureHiddenFileSync("DISABLE_CUSTOM");
}
}
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
if (isPluginMetadata(path)) {
return Promise.resolve("newer");
@@ -1826,7 +1793,6 @@ export class ConfigSync extends LiveSyncCommands {
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
services.setting.suspendExtraSync.addHandler(this._allSuspendExtraSync.bind(this));
services.setting.suggestOptionalFeatures.addHandler(this._allAskUsingOptionalSyncFeature.bind(this));
services.setting.enableOptionalFeature.addHandler(this._allConfigureOptionalSyncFeature.bind(this));
}
}
@@ -46,7 +46,10 @@ import { JsonResolveModal } from "@/features/HiddenFileCommon/JsonResolveModal.t
import { LiveSyncCommands } from "@/features/LiveSyncCommands.ts";
import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { QueueProcessor } from "octagonal-wheels/concurrency/processor";
import { hiddenFilesEventCount, hiddenFilesProcessingCount } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import {
hiddenFilesEventCount,
hiddenFilesProcessingCount,
} from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "@/main.ts";
@@ -1765,56 +1768,6 @@ Offline Changed files: ${files.length}`;
// <-- Database To Storage Functions
private async _allAskUsingOptionalSyncFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
await this.__askHiddenFileConfiguration(opt);
return true;
}
private async __askHiddenFileConfiguration(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
const messageFetch = `${opt.enableFetch ? `> - Fetch: Use the files stored from other devices. Choose this option if you have already configured hidden file synchronization on those devices and wish to accept their files.\n` : ""}`;
const messageOverwrite = `${opt.enableOverwrite ? `> - Overwrite: Use the files from this device. Select this option if you want to overwrite the files stored on other devices.\n` : ""}`;
const messageMerge = `> - Merge: Merge the files from this device with those on other devices. Choose this option if you wish to combine files from multiple sources.
> However, please be reminded that merging may cause conflicts if the files are not identical. Additionally, this process may occur within the same folder, potentially breaking your plug-in or theme settings that comprise multiple files.\n`;
const message = `Would you like to enable **Hidden File Synchronization**?
> [!DETAILS]-
> This feature allows you to synchronize all hidden files without any user interaction.
> To enable this feature, you should choose one of the following options:
${messageFetch}${messageOverwrite}${messageMerge}
> [!IMPORTANT]
> Please keep in mind that enabling this feature alongside customisation sync may override certain behaviors.`;
const CHOICE_FETCH = "Fetch";
const CHOICE_OVERWRITE = "Overwrite";
const CHOICE_MERGE = "Merge";
const CHOICE_DISABLE = "Disable";
const choices = [];
if (opt?.enableFetch) {
choices.push(CHOICE_FETCH);
}
if (opt?.enableOverwrite) {
choices.push(CHOICE_OVERWRITE);
}
choices.push(CHOICE_MERGE);
choices.push(CHOICE_DISABLE);
const ret = await this.core.confirm.confirmWithMessage(
"Hidden file sync",
message,
choices,
CHOICE_DISABLE,
40
);
if (ret == CHOICE_FETCH) {
await this.configureHiddenFileSync("FETCH");
} else if (ret == CHOICE_OVERWRITE) {
await this.configureHiddenFileSync("OVERWRITE");
} else if (ret == CHOICE_MERGE) {
await this.configureHiddenFileSync("MERGE");
} else if (ret == CHOICE_DISABLE) {
await this.configureHiddenFileSync("DISABLE_HIDDEN");
}
}
private _allSuspendExtraSync(): Promise<boolean> {
if (this.core.settings.syncInternalFiles) {
this._log(
@@ -1988,7 +1941,6 @@ ${messageFetch}${messageOverwrite}${messageMerge}
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
services.setting.suspendExtraSync.addHandler(this._allSuspendExtraSync.bind(this));
services.setting.suggestOptionalFeatures.addHandler(this._allAskUsingOptionalSyncFeature.bind(this));
services.setting.enableOptionalFeature.addHandler(this._allConfigureOptionalSyncFeature.bind(this));
services.vault.isTargetFileInExtra.addHandler((file) =>
this.isTargetFile((typeof file === "string" ? file : stripAllPrefixes(file.path)) as FilePath)
+3
View File
@@ -43,6 +43,8 @@ import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/repli
import { useP2PReplicatorCommands } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorCommands";
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
import { useCompatibilityReview } from "./serviceFeatures/compatibilityReview.ts";
import { createObsidianCompatibilityReviewUi } from "./serviceFeatures/compatibilityReviewObsidian.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore;
@@ -192,6 +194,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
useOfflineScanner(core);
useRedFlagFeatures(core);
useCheckRemoteSize(core);
useCompatibilityReview(core, createObsidianCompatibilityReviewUi(core.confirm));
// p2pReplicatorResult = useP2PReplicator(core, [
// VIEW_TYPE_P2P,
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
@@ -188,6 +188,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
override onOpen() {
this.component.load();
const { contentEl } = this;
contentEl.closest(".modal-container")?.classList.add("livesync-message-box-container");
this.titleEl.setText(this.title);
const div = contentEl.createDiv();
div.setCssStyles({
@@ -14,7 +14,6 @@ import {
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { delay, isObjectDifferent, sizeToHumanReadable } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
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";
@@ -34,7 +33,6 @@ import {
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
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 {
@@ -417,11 +415,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) {
@@ -623,7 +616,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())) {
@@ -829,18 +822,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,61 +1,11 @@
import { MarkdownRenderer } from "@/deps.ts";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
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
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)
);
+5 -27
View File
@@ -1,5 +1,9 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, VER, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
EVENT_LAYOUT_READY,
EVENT_PLUGIN_LOADED,
@@ -8,13 +12,11 @@ import {
eventHub,
} from "@/common/events.ts";
import { $msg, setLang } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { versionNumberString2Number } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/convert";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { LiveSyncCore } from "@/main.ts";
import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker";
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
export class ModuleLiveSyncMain extends AbstractModule {
async _onLiveSyncReady() {
@@ -97,30 +99,6 @@ export class ModuleLiveSyncMain extends AbstractModule {
this._log($msg("moduleLiveSyncMain.logPluginInitCancelled"), LOG_LEVEL_NOTICE);
return false;
}
const lsKey = "obsidian-live-sync-ver" + this.services.vault.getVaultName();
const last_version = compatGlobal.localStorage.getItem(lsKey);
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
this._log($msg("moduleLiveSyncMain.logReadChangelog"), LOG_LEVEL_NOTICE);
}
// //@ts-ignore
// if (this.isMobile) {
// this.settings.disableRequestURI = true;
// }
if (last_version && Number(last_version) < VER) {
this.settings.liveSync = false;
this.settings.syncOnSave = false;
this.settings.syncOnEditorSave = false;
this.settings.syncOnStart = false;
this.settings.syncOnFileOpen = false;
this.settings.syncAfterMerge = false;
this.settings.periodicReplication = false;
this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate");
await this.saveSettings();
}
compatGlobal.localStorage.setItem(lsKey, `${VER}`);
await this.services.database.openDatabase({
databaseEvents: this.services.databaseEvents,
replicator: this.services.replicator,
@@ -54,6 +54,14 @@
max-height: 100%;
}
:global(body.is-mobile .livesync-svelte-dialog-container .dialog-host > .button-group) {
background: var(--modal-background, var(--background-primary));
bottom: 0;
padding-bottom: 1px;
position: sticky;
z-index: 1;
}
.dialog-host {
padding: 20px;
gap: 0.5em;
+143 -25
View File
@@ -1,18 +1,19 @@
import { type App, type Plugin, Notice } from "@/deps";
import { scheduleTask, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject } from "@/common/utils";
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import { confirmAction, pickOne, promptPassword, promptText } from "@vrtmrz/obsidian-plugin-kit";
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import {
askYesNo,
askString,
confirmWithMessageWithWideButton,
askSelectString,
confirmWithMessage,
confirmWithMessage as confirmWithLegacyMessage,
} from "@/modules/coreObsidian/UILib/dialogs";
export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceContext> implements Confirm {
private _context: T;
private readonly dialogueController = new AbortController();
private readonly popupKeys = new Set<string>();
get _app(): App {
return this._context.app;
}
@@ -21,12 +22,58 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
}
constructor(context: T) {
this._context = context;
context.events.onceEvent(EVENT_PLUGIN_UNLOADED, () => {
this.dialogueController.abort();
for (const popupKey of this.popupKeys) {
this.closePopup(popupKey);
}
});
}
askYesNo(message: string): Promise<"yes" | "no"> {
return askYesNo(this._app, message);
private get dialogueLifecycle() {
return { signal: this.dialogueController.signal };
}
askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> {
return askString(this._app, title, key, placeholder, isPassword);
private hasCountdown(timeout: number | undefined): timeout is number {
return timeout !== undefined && timeout > 0;
}
async askYesNo(message: string): Promise<"yes" | "no"> {
const result = await confirmAction(
this._app,
{
title: $msg("moduleInputUIObsidian.defaultTitleConfirmation"),
message,
actions: ["yes", "no"] as const,
labels: {
yes: $msg("moduleInputUIObsidian.optionYes"),
no: $msg("moduleInputUIObsidian.optionNo"),
},
defaultAction: "no",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "yes" ? "yes" : "no";
}
async askString(
title: string,
key: string,
placeholder: string,
isPassword: boolean = false
): Promise<string | false> {
const prompt = isPassword ? promptPassword : promptText;
const result = await prompt(
this._app,
{
title,
label: key,
placeholder,
},
this.dialogueLifecycle
);
return result ?? false;
}
async askYesNoDialog(
@@ -37,6 +84,21 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
const yesLabel = $msg("moduleInputUIObsidian.optionYes");
const noLabel = $msg("moduleInputUIObsidian.optionNo");
const defaultOption = opt.defaultOption === "Yes" ? yesLabel : noLabel;
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: ["Yes", "No"] as const,
labels: { Yes: yesLabel, No: noLabel },
defaultAction: opt.defaultOption === "Yes" ? "Yes" : "No",
sourcePath: "/",
},
this.dialogueLifecycle
);
return result === "Yes" ? "yes" : "no";
}
const ret = await confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -48,16 +110,39 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
return ret === yesLabel ? "yes" : "no";
}
askSelectString(message: string, items: string[]): Promise<string> {
return askSelectString(this._app, message, items);
async askSelectString(message: string, items: string[]): Promise<string> {
const result = await pickOne(
this._app,
{
items,
getText: (item) => item,
placeholder: message,
},
this.dialogueLifecycle
);
return result ?? "";
}
askSelectStringDialogue<T extends readonly string[]>(
async askSelectStringDialogue<T extends readonly string[]>(
message: string,
buttons: T,
opt: { title?: string; defaultAction: T[number]; timeout?: number }
): Promise<T[number] | false> {
const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect");
if (!this.hasCountdown(opt.timeout)) {
const result = await confirmAction(
this._app,
{
title: opt.title || defaultTitle,
message,
actions: buttons,
defaultAction: opt.defaultAction,
sourcePath: "/",
},
this.dialogueLifecycle
);
return result ?? false;
}
return confirmWithMessageWithWideButton(
this._plugin,
opt.title || defaultTitle,
@@ -68,7 +153,14 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
);
}
askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) {
askInPopup(
key: string,
dialogText: string,
anchorCallback: (anchor: HTMLAnchorElement) => void,
durationMs: number = 20000
) {
const popupKey = "popup-" + key;
this.popupKeys.add(popupKey);
const fragment = createFragment((doc) => {
const [beforeText, afterText] = dialogText.split("{HERE}", 2);
doc.createSpan(undefined, (a) => {
@@ -76,36 +168,62 @@ export class ObsidianConfirm<T extends ObsidianServiceContext = ObsidianServiceC
a.appendChild(
a.createEl("a", undefined, (anchor) => {
anchorCallback(anchor);
anchor.addEventListener("click", () => this.closePopup(popupKey));
})
);
a.appendText(afterText);
});
});
const popupKey = "popup-" + key;
scheduleTask(popupKey, 1000, async () => {
if (this.dialogueController.signal.aborted) {
this.popupKeys.delete(popupKey);
return;
}
const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0));
const isShown = popup?.noticeEl?.isShown();
if (!isShown) {
memoObject(popupKey, new Notice(fragment, 0));
}
scheduleTask(popupKey + "-close", 20000, () => {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) return;
if (popup?.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
});
scheduleTask(popupKey + "-close", durationMs, () => this.closePopup(popupKey));
});
}
confirmWithMessage(
private closePopup(popupKey: string) {
const popup = retrieveMemoObject<Notice>(popupKey);
if (!popup) {
this.popupKeys.delete(popupKey);
return;
}
if (popup.noticeEl?.isShown()) {
popup.hide();
}
disposeMemoObject(popupKey);
this.popupKeys.delete(popupKey);
}
async confirmWithMessage(
title: string,
contentMd: string,
buttons: string[],
defaultAction: (typeof buttons)[number],
timeout?: number
timeout?: number,
actionLayout?: ConfirmActionLayout
): Promise<(typeof buttons)[number] | false> {
return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
if (this.hasCountdown(timeout)) {
return confirmWithLegacyMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout);
}
const result = await confirmAction(
this._app,
{
title,
message: contentMd,
actions: buttons,
defaultAction,
sourcePath: "/",
...(actionLayout === undefined ? {} : { actionLayout }),
},
this.dialogueLifecycle
);
return result ?? false;
}
}
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
confirmAction: vi.fn(),
pickOne: vi.fn(),
promptPassword: vi.fn(),
promptText: vi.fn(),
legacyAskSelectString: vi.fn(),
legacyAskString: vi.fn(),
legacyAskYesNo: vi.fn(),
legacyConfirm: vi.fn(),
legacyWideConfirm: vi.fn(),
}));
vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({
confirmAction: mocks.confirmAction,
pickOne: mocks.pickOne,
promptPassword: mocks.promptPassword,
promptText: mocks.promptText,
}));
vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({
askSelectString: mocks.legacyAskSelectString,
askString: mocks.legacyAskString,
askYesNo: mocks.legacyAskYesNo,
confirmWithMessage: mocks.legacyConfirm,
confirmWithMessageWithWideButton: mocks.legacyWideConfirm,
}));
vi.mock("@/deps", () => ({
Notice: class {},
}));
import { EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { memoObject, retrieveMemoObject } from "@/common/utils";
import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context";
import { ObsidianConfirm } from "./ObsidianConfirm";
import type { ObsidianServiceContext } from "./ObsidianServiceContext";
function createConfirm() {
const app = { id: "app" };
const plugin = { app };
const events = createLiveSyncEventHub();
const context = { app, plugin, events } as unknown as ObsidianServiceContext;
return { confirm: new ObsidianConfirm(context), events, app, plugin };
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("ObsidianConfirm Fancy Kit adapter", () => {
it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => {
const { confirm, app } = createConfirm();
mocks.promptText.mockResolvedValueOnce(null);
mocks.promptPassword.mockResolvedValueOnce("");
await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false);
await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe("");
expect(mocks.promptText).toHaveBeenCalledWith(
app,
{
title: "Name",
label: "Device name",
placeholder: "New Remote",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.promptPassword).toHaveBeenCalledWith(
app,
{
title: "Secret",
label: "Passphrase",
placeholder: "Enter it",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskString).not.toHaveBeenCalled();
});
it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => {
const { confirm, app } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("yes");
mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null);
await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes");
await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta");
await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe("");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
expect.objectContaining({
message: "Continue?",
actions: ["yes", "no"],
defaultAction: "no",
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.pickOne).toHaveBeenCalledWith(
app,
expect.objectContaining({
items: ["Alpha", "Beta"],
getText: expect.any(Function),
}),
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyAskYesNo).not.toHaveBeenCalled();
expect(mocks.legacyAskSelectString).not.toHaveBeenCalled();
});
it("uses Kit for untimed action dialogues and retains the legacy countdown path", async () => {
const { confirm, app, plugin } = createConfirm();
mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes");
mocks.legacyConfirm.mockResolvedValueOnce("Cancel");
mocks.legacyWideConfirm.mockResolvedValueOnce("No");
await expect(
confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel", undefined, "vertical")
).resolves.toBe("Apply");
await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe(
"yes"
);
await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe(
"Cancel"
);
await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no");
expect(mocks.confirmAction).toHaveBeenNthCalledWith(
1,
app,
{
title: "Review",
message: "**Apply?**",
actions: ["Apply", "Cancel"],
actionLayout: "vertical",
defaultAction: "Cancel",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyConfirm).toHaveBeenCalledWith(plugin, "Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
expect.any(String),
"Timed?",
expect.any(Array),
expect.any(String),
10
);
});
it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => {
const { confirm, app, plugin } = createConfirm();
const actions = ["Apply now", "Review later"] as const;
mocks.confirmAction.mockResolvedValueOnce("Review later");
mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now");
await expect(
confirm.askSelectStringDialogue("Choose the next step", actions, {
title: "Next step",
defaultAction: "Review later",
})
).resolves.toBe("Review later");
await expect(
confirm.askSelectStringDialogue("Choose before the timer expires", actions, {
title: "Timed step",
defaultAction: "Apply now",
timeout: 15,
})
).resolves.toBe("Apply now");
expect(mocks.confirmAction).toHaveBeenCalledWith(
app,
{
title: "Next step",
message: "Choose the next step",
actions,
defaultAction: "Review later",
sourcePath: "/",
},
{ signal: expect.any(AbortSignal) }
);
expect(mocks.legacyWideConfirm).toHaveBeenCalledWith(
plugin,
"Timed step",
"Choose before the timer expires",
actions,
"Apply now",
15
);
});
it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => {
const { confirm, events } = createConfirm();
let observedSignal: AbortSignal | undefined;
mocks.confirmAction.mockImplementation(
(_app, _options, lifecycle: { signal: AbortSignal }) =>
new Promise<null>((resolve) => {
observedSignal = lifecycle.signal;
lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true });
})
);
const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK");
expect(observedSignal?.aborted).toBe(false);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
await expect(result).resolves.toBe(false);
expect(observedSignal?.aborted).toBe(true);
});
it("closes an active Notice when the plug-in unload event is emitted", () => {
const { confirm, events } = createConfirm();
const popupKey = "popup-remote-size-exceeded";
const popup = {
hide: vi.fn(),
noticeEl: { isShown: vi.fn(() => true) },
};
memoObject(popupKey, popup);
(confirm as unknown as { popupKeys: Set<string> }).popupKeys.add(popupKey);
events.emitEvent(EVENT_PLUGIN_UNLOADED);
expect(popup.hide).toHaveBeenCalledOnce();
expect(retrieveMemoObject(popupKey)).toBe(false);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { fireAndForget } from "octagonal-wheels/promises";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { LiveSyncCore } from "@/main.ts";
import {
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
DATABASE_COMPATIBILITY_VERSION_KEY,
evaluateCompatibilityPause,
legacyDatabaseCompatibilityVersionKey,
type CompatibilityPause,
} from "@/common/databaseCompatibility.ts";
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
export type CompatibilityReviewDetailsAction = "back" | false;
export interface CompatibilityReviewUi {
showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction>;
showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction>;
showReminder(openReview: () => void): void;
clearReminder(): void;
}
export class CompatibilityReviewController {
private pause: CompatibilityPause | undefined;
private activeReview: Promise<void> | undefined;
private disposed = false;
constructor(
private readonly core: LiveSyncCore,
private readonly ui: CompatibilityReviewUi,
private readonly currentVersion: number = VER
) {}
get pendingPause(): CompatibilityPause | undefined {
return this.pause;
}
private readAcknowledgedVersion(): string | null {
const setting = this.core.services.setting;
const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY);
if (currentMarker) return currentMarker;
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
const legacyMarker = setting.getDeviceLocalConfig(legacyKey);
if (!legacyMarker) return null;
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker);
setting.deleteDeviceLocalConfig(legacyKey);
return legacyMarker;
}
async initialise(): Promise<boolean> {
if (this.disposed) return true;
const setting = this.core.services.setting;
const settings = setting.currentSettings();
const acknowledgedVersion = this.readAcknowledgedVersion();
const evaluation = evaluateCompatibilityPause({
acknowledgedVersion,
currentVersion: this.currentVersion,
migrationState: setting.getSettingsMigrationState(),
legacyReviewMessage: settings.versionUpFlash,
});
this.pause = evaluation.pause;
if (evaluation.initialiseAcknowledgedVersion) {
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
return true;
}
if (!this.pause) return true;
if (settings.versionUpFlash === "") {
settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE;
await setting.saveSettingData();
}
return true;
}
private async acknowledge(): Promise<void> {
if (!this.pause?.resumable) return;
const setting = this.core.services.setting;
const settings = setting.currentSettings();
const previousMessage = settings.versionUpFlash;
settings.versionUpFlash = "";
try {
await setting.saveSettingData();
} catch (error) {
settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE;
throw error;
}
setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`);
const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName());
setting.deleteDeviceLocalConfig(legacyKey);
this.pause = undefined;
this.ui.clearReminder();
await this.core.services.control.applySettings();
}
private async runReview(): Promise<void> {
while (this.pause) {
const action = await this.ui.showSummary(this.pause);
if (action === "details") {
const detailsAction = await this.ui.showDetails(this.pause);
if (detailsAction === "back") continue;
break;
}
if (action === "resume" && this.pause.resumable) {
await this.acknowledge();
return;
}
break;
}
if (this.pause && !this.disposed) {
this.ui.showReminder(() => {
fireAndForget(() => this.openReview());
});
}
}
openReview(): Promise<void> {
if (this.disposed || !this.pause) return Promise.resolve();
if (this.activeReview) return this.activeReview;
this.ui.clearReminder();
this.activeReview = this.runReview().finally(() => {
this.activeReview = undefined;
});
return this.activeReview;
}
dispose(): void {
this.disposed = true;
this.pause = undefined;
this.ui.clearReminder();
}
}
export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController {
const controller = new CompatibilityReviewController(core, ui);
core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise());
core.services.appLifecycle.onLayoutReady.addHandler(() => {
fireAndForget(() => controller.openReview());
return Promise.resolve(true);
});
core.services.appLifecycle.onUnload.addHandler(() => {
controller.dispose();
return Promise.resolve(true);
});
core.services.API.addCommand({
id: "livesync-review-compatibility-pause",
name: "Review why synchronisation is paused",
checkCallback: (checking) => {
if (!controller.pendingPause) return false;
if (!checking) fireAndForget(() => controller.openReview());
return true;
},
});
return controller;
}
@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
COMPATIBILITY_PAUSE_SETTING_MESSAGE,
DATABASE_COMPATIBILITY_VERSION_KEY,
legacyDatabaseCompatibilityVersionKey,
} from "@/common/databaseCompatibility.ts";
import { CompatibilityReviewController, type CompatibilityReviewUi } from "./compatibilityReview.ts";
function migrationState(overrides: Record<string, unknown> = {}) {
return {
sourceVersion: 2,
targetVersion: 2,
isNewVault: false,
isFromFutureSchema: false,
changed: false,
requiresSyncReview: false,
reviewReasons: [],
...overrides,
};
}
function createFixture(
options: {
marker?: string | null;
legacyMarker?: string | null;
versionUpFlash?: string;
migration?: Record<string, unknown>;
} = {}
) {
const local = new Map<string, string>();
if (options.marker !== undefined && options.marker !== null) {
local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker);
}
const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault");
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
local.set(legacyKey, options.legacyMarker);
}
const settings = { versionUpFlash: options.versionUpFlash ?? "" };
const saveSettingData = vi.fn().mockResolvedValue(undefined);
const applySettings = vi.fn().mockResolvedValue(true);
const setting = {
currentSettings: () => settings,
getSettingsMigrationState: () => migrationState(options.migration),
getSmallConfig: (key: string) => local.get(key) ?? "",
setSmallConfig: (key: string, value: string) => local.set(key, value),
getDeviceLocalConfig: (key: string) => local.get(key) ?? null,
deleteDeviceLocalConfig: (key: string) => local.delete(key),
saveSettingData,
};
const core = {
services: {
setting,
vault: { getVaultName: () => "Test Vault" },
control: { applySettings },
},
} as never;
const ui: CompatibilityReviewUi = {
showSummary: vi.fn().mockResolvedValue("keep-paused"),
showDetails: vi.fn().mockResolvedValue(false),
showReminder: vi.fn(),
clearReminder: vi.fn(),
};
const controller = new CompatibilityReviewController(core, ui, 12);
return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings };
}
describe("compatibility review controller", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
await expect(fixture.controller.initialise()).resolves.toBe(true);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.saveSettingData).not.toHaveBeenCalled();
});
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.saveSettingData).toHaveBeenCalledTimes(1);
await fixture.controller.openReview();
expect(fixture.settings.versionUpFlash).toBe("");
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
expect(fixture.saveSettingData).toHaveBeenCalledTimes(2);
expect(fixture.applySettings).toHaveBeenCalledOnce();
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.ui.clearReminder).toHaveBeenCalled();
});
it("does not allow a downgrade pause to be resumed", async () => {
const fixture = createFixture({ marker: "13" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.controller.pendingPause?.resumable).toBe(false);
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("13");
expect(fixture.applySettings).not.toHaveBeenCalled();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
});
it("returns from details to the reason dialogue and leaves a persistent reminder", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValueOnce("details").mockResolvedValueOnce("keep-paused");
vi.mocked(fixture.ui.showDetails).mockResolvedValue("back");
await fixture.controller.initialise();
await fixture.controller.openReview();
expect(fixture.ui.showSummary).toHaveBeenCalledTimes(2);
expect(fixture.ui.showDetails).toHaveBeenCalledOnce();
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
});
it("migrates the old Vault-scoped marker to Commonlib device-local storage", async () => {
const fixture = createFixture({ legacyMarker: "11" });
await fixture.controller.initialise();
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.local.has(fixture.legacyKey)).toBe(false);
expect(fixture.controller.pendingPause).toBeDefined();
});
it("restores the runtime gate if persisting an acknowledgement fails", async () => {
const fixture = createFixture({ marker: "11" });
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
await fixture.controller.initialise();
fixture.saveSettingData.mockRejectedValueOnce(new Error("save failed"));
await expect(fixture.controller.openReview()).rejects.toThrow("save failed");
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11");
expect(fixture.applySettings).not.toHaveBeenCalled();
});
it("does not open a delayed review after the controller has been disposed", async () => {
const fixture = createFixture({ marker: "11" });
await fixture.controller.initialise();
fixture.controller.dispose();
await fixture.controller.openReview();
expect(fixture.controller.pendingPause).toBeUndefined();
expect(fixture.ui.showSummary).not.toHaveBeenCalled();
expect(fixture.ui.clearReminder).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,131 @@
import { Notice } from "@/deps.ts";
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
import type {
CompatibilityReviewDetailsAction,
CompatibilityReviewSummaryAction,
CompatibilityReviewUi,
} from "./compatibilityReview.ts";
const REVIEW_DETAILS = "Review compatibility details";
const KEEP_PAUSED = "Keep synchronisation paused";
const RESUME = "Resume synchronisation";
const BACK = "Back to compatibility review";
function summaryMarkdown(pause: CompatibilityPause): string {
const action = pause.resumable
? "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database."
: "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again.";
return `Remote synchronisation is paused on this device because its compatibility state requires attention.
${action}
Your automatic synchronisation preferences have not been changed. Closing this dialogue keeps synchronisation paused.`;
}
function reasonMarkdown(reason: CompatibilityPauseReason): string {
if (reason.source === "database-version") {
if (reason.state === "upgrade") {
return `- The last acknowledged internal database version was **${reason.acknowledgedVersion}** and this installation uses **${reason.currentVersion}**.`;
}
if (reason.state === "downgrade") {
return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`;
}
if (reason.state === "missing") {
return `- No previously acknowledged internal database version was found for this existing Vault. This installation uses version **${reason.currentVersion}**.`;
}
return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`;
}
if (reason.source === "settings-schema") {
if (reason.isFromFutureSchema) {
return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`;
}
return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`;
}
const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&");
return `- An earlier compatibility review remains pending: ${escapedMessage}`;
}
function detailsMarkdown(pause: CompatibilityPause): string {
const resolution = pause.resumable
? "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged."
: "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation.";
return `## Why synchronisation is paused
${pause.reasons.map(reasonMarkdown).join("\n")}
## What the pause changes
- Remote replication is blocked before work begins.
- Your saved automatic synchronisation preferences remain unchanged.
- Closing either dialogue leaves the safety gate active.
## What to do next
${resolution}`;
}
export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
private reminder: Notice | undefined;
constructor(private readonly confirm: Confirm) {}
async showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction> {
const buttons = pause.resumable
? ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const)
: ([REVIEW_DETAILS, KEEP_PAUSED] as const);
const result = await this.confirm.confirmWithMessage(
"Synchronisation paused for compatibility review",
summaryMarkdown(pause),
[...buttons],
KEEP_PAUSED,
undefined,
"vertical"
);
if (result === REVIEW_DETAILS) return "details";
if (result === RESUME) return "resume";
if (result === KEEP_PAUSED) return "keep-paused";
return false;
}
async showDetails(pause: CompatibilityPause): Promise<CompatibilityReviewDetailsAction> {
const result = await this.confirm.confirmWithMessage(
"Compatibility review details",
detailsMarkdown(pause),
[BACK],
BACK,
undefined,
"vertical"
);
if (result === BACK) return "back";
return false;
}
showReminder(openReview: () => void): void {
this.clearReminder();
let reminderAnchor: HTMLAnchorElement | undefined;
const fragment = createFragment((documentFragment) => {
documentFragment.createSpan({
text: "Self-hosted LiveSync has paused remote synchronisation for compatibility review. ",
});
documentFragment.createEl("a", { text: "Review why" }, (anchor) => {
reminderAnchor = anchor;
anchor.addEventListener("click", (event) => {
event.preventDefault();
openReview();
});
});
});
this.reminder = new Notice(fragment, 0);
reminderAnchor?.closest<HTMLElement>(".notice")?.classList.add("livesync-compatibility-review-notice");
}
clearReminder(): void {
this.reminder?.hide();
this.reminder = undefined;
}
}
export function createObsidianCompatibilityReviewUi(confirm: Confirm): CompatibilityReviewUi {
return new ObsidianCompatibilityReviewUi(confirm);
}