mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Prepare the 1.0 compatibility review flow
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "0.25.83-webpeer",
|
||||
"version": "1.0.0-rc.0-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user