mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Align host settings with the 1.0 lifecycle
This commit is contained in:
@@ -337,7 +337,7 @@ Options:
|
||||
|
||||
Commands:
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
||||
init-settings [path] Create unconfigured settings JSON with the new-Vault recommendations
|
||||
sync Run one replication cycle and exit
|
||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
export function createDefaultCliSettings(): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
...createNewVaultSettings(),
|
||||
useIndexedDBAdapter: false,
|
||||
isConfigured: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createDefaultCliSettings } from "./cliSettingsDefaults.ts";
|
||||
|
||||
describe("createDefaultCliSettings", () => {
|
||||
it("uses the recommended new-Vault settings with the Node database adapter", () => {
|
||||
const settings = createDefaultCliSettings();
|
||||
const recommended = createNewVaultSettings();
|
||||
|
||||
expect(settings).toEqual({
|
||||
...recommended,
|
||||
useIndexedDBAdapter: false,
|
||||
isConfigured: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./servi
|
||||
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type LOG_LEVEL,
|
||||
type ObsidianLiveSyncSettings,
|
||||
@@ -28,6 +27,7 @@ import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/repli
|
||||
import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
import { createDefaultCliSettings } from "./cliSettingsDefaults";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -257,10 +257,7 @@ async function createDefaultSettingsFile(options: CLIOptions, standardIo: Standa
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
useIndexedDBAdapter: false,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
const settings = createDefaultCliSettings();
|
||||
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, JSON.stringify(settings, null, 2), "utf-8");
|
||||
|
||||
@@ -93,9 +93,8 @@ data.encrypt = true;
|
||||
data.passphrase = process.env.PASSPHRASE_VAL;
|
||||
data.usePathObfuscation = true;
|
||||
data.handleFilenameCaseSensitive = false;
|
||||
data.customChunkSize = 50;
|
||||
data.customChunkSize = 60;
|
||||
data.usePluginSyncV2 = true;
|
||||
data.doNotUseFixedRevisionForChunks = false;
|
||||
data.P2P_DevicePeerName = process.env.DEVICE_NAME;
|
||||
data.isConfigured = true;
|
||||
|
||||
|
||||
@@ -195,9 +195,8 @@ export async function applyP2pTestTweaks(settingsFile: string, deviceName: strin
|
||||
data.passphrase = passphrase;
|
||||
data.usePathObfuscation = true;
|
||||
data.handleFilenameCaseSensitive = false;
|
||||
data.customChunkSize = 50;
|
||||
data.customChunkSize = 60;
|
||||
data.usePluginSyncV2 = true;
|
||||
data.doNotUseFixedRevisionForChunks = false;
|
||||
data.P2P_DevicePeerName = deviceName;
|
||||
data.isConfigured = true;
|
||||
await Deno.writeTextFile(settingsFile, JSON.stringify(data, null, 2));
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { SettingsMigrationState } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
SettingsMigrationReviewReason,
|
||||
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";
|
||||
@@ -21,6 +24,7 @@ export interface SettingsCompatibilityReason {
|
||||
currentVersion: number;
|
||||
isFromFutureSchema: boolean;
|
||||
resumable: boolean;
|
||||
reviewReasons: readonly SettingsMigrationReviewReason[];
|
||||
}
|
||||
|
||||
export interface LegacyCompatibilityReason {
|
||||
@@ -51,6 +55,16 @@ export interface CompatibilityEvaluationInput {
|
||||
legacyReviewMessage: string;
|
||||
}
|
||||
|
||||
const FILENAME_CASE_SENSITIVITY_UNRESOLVED = "filename-case-sensitivity-unresolved";
|
||||
|
||||
export function requiresFilenameCaseSensitivityDecision(pause: CompatibilityPause): boolean {
|
||||
return pause.reasons.some(
|
||||
(reason) =>
|
||||
reason.source === "settings-schema" &&
|
||||
reason.reviewReasons.some(({ code }) => code === FILENAME_CASE_SENSITIVITY_UNRESOLVED)
|
||||
);
|
||||
}
|
||||
|
||||
function databaseVersionReason(
|
||||
acknowledgedVersion: string | null,
|
||||
currentVersion: number,
|
||||
@@ -112,6 +126,7 @@ export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput):
|
||||
currentVersion: input.migrationState.targetVersion,
|
||||
isFromFutureSchema: input.migrationState.isFromFutureSchema,
|
||||
resumable: !input.migrationState.isFromFutureSchema,
|
||||
reviewReasons: input.migrationState.reviewReasons,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -103,11 +103,42 @@ describe("database compatibility evaluation", () => {
|
||||
currentVersion: 2,
|
||||
isFromFutureSchema: true,
|
||||
resumable: false,
|
||||
reviewReasons: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains an unresolved filename-case decision in the host compatibility reason", () => {
|
||||
const reviewReasons = [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
];
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: "12",
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState({
|
||||
sourceVersion: 10,
|
||||
targetVersion: 10,
|
||||
requiresSyncReview: true,
|
||||
reviewReasons,
|
||||
}),
|
||||
legacyReviewMessage: "",
|
||||
});
|
||||
|
||||
expect(result.pause?.reasons).toContainEqual({
|
||||
source: "settings-schema",
|
||||
sourceVersion: 10,
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons,
|
||||
});
|
||||
});
|
||||
|
||||
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: "12",
|
||||
|
||||
@@ -3,8 +3,12 @@ import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types
|
||||
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
|
||||
|
||||
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn<() => Promise<"Apply and continue" | "Cancel" | false | undefined>>(
|
||||
async () => "Apply and continue"
|
||||
const askSelectStringDialogue = vi.fn(
|
||||
async (
|
||||
_message: string,
|
||||
_buttons: readonly ["Apply and continue", "Cancel"],
|
||||
_options: { title: string; defaultAction: "Cancel" }
|
||||
): Promise<"Apply and continue" | "Cancel" | false | undefined> => "Apply and continue"
|
||||
);
|
||||
const applyPartial = vi.fn(async () => undefined);
|
||||
const settings = {
|
||||
@@ -18,13 +22,12 @@ function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS>
|
||||
}
|
||||
|
||||
describe("LocalDatabaseMaintenance prerequisites", () => {
|
||||
it("asks to apply missing prerequisite settings before maintenance actions", async () => {
|
||||
it("asks to disable on-demand chunk fetching before maintenance actions", async () => {
|
||||
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
|
||||
|
||||
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||
operationName: "Garbage Collection",
|
||||
settings: {
|
||||
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
|
||||
readChunksOnline: settings.readChunksOnline,
|
||||
},
|
||||
askSelectStringDialogue,
|
||||
@@ -42,11 +45,11 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
|
||||
);
|
||||
expect(applyPartial).toHaveBeenCalledWith(
|
||||
{
|
||||
doNotUseFixedRevisionForChunks: true,
|
||||
readChunksOnline: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0]).not.toContain("Compute revisions for chunks");
|
||||
});
|
||||
|
||||
it("cancels maintenance actions when prerequisite changes are rejected", async () => {
|
||||
@@ -56,7 +59,6 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
|
||||
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||
operationName: "Garbage Collection",
|
||||
settings: {
|
||||
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
|
||||
readChunksOnline: settings.readChunksOnline,
|
||||
},
|
||||
askSelectStringDialogue,
|
||||
@@ -76,7 +78,6 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
|
||||
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||
operationName: "Garbage Collection",
|
||||
settings: {
|
||||
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
|
||||
readChunksOnline: settings.readChunksOnline,
|
||||
},
|
||||
askSelectStringDialogue,
|
||||
@@ -86,4 +87,24 @@ describe("LocalDatabaseMaintenance prerequisites", () => {
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(applyPartial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not treat the obsolete fixed-revision key as a maintenance prerequisite", async () => {
|
||||
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
|
||||
doNotUseFixedRevisionForChunks: false,
|
||||
readChunksOnline: false,
|
||||
});
|
||||
|
||||
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||
operationName: "Garbage Collection",
|
||||
settings: {
|
||||
readChunksOnline: settings.readChunksOnline,
|
||||
},
|
||||
askSelectStringDialogue,
|
||||
applyPartial,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(applyPartial).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat
|
||||
|
||||
type MaintenancePrerequisiteSettings = Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
"doNotUseFixedRevisionForChunks" | "readChunksOnline"
|
||||
"readChunksOnline"
|
||||
>;
|
||||
|
||||
type MaintenancePrerequisiteOptions = {
|
||||
@@ -23,14 +23,10 @@ export async function ensureLocalDatabaseMaintenancePrerequisites({
|
||||
applyPartial,
|
||||
}: MaintenancePrerequisiteOptions): Promise<boolean> {
|
||||
const requiredSettings = {
|
||||
doNotUseFixedRevisionForChunks: true,
|
||||
readChunksOnline: false,
|
||||
} satisfies MaintenancePrerequisiteSettings;
|
||||
|
||||
const missing = [
|
||||
...(settings.doNotUseFixedRevisionForChunks ? [] : ["- Compute revisions for chunks: On (currently Off)"]),
|
||||
...(settings.readChunksOnline ? ["- Fetch chunks on demand: Off (currently On)"] : []),
|
||||
];
|
||||
const missing = settings.readChunksOnline ? ["- Fetch chunks on demand: Off (currently On)"] : [];
|
||||
|
||||
if (missing.length == 0) return true;
|
||||
|
||||
|
||||
@@ -117,7 +117,6 @@ const PRESERVED_SYNC_SETTING_KEYS = [
|
||||
const NEW_VAULT_RECOMMENDATION_KEYS = [
|
||||
"syncMaxSizeInMB",
|
||||
"chunkSplitterVersion",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
"usePluginSyncV2",
|
||||
"handleFilenameCaseSensitive",
|
||||
"E2EEAlgorithm",
|
||||
|
||||
@@ -48,6 +48,7 @@ function compatibilityPause(): CompatibilityPause {
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons: migration().reviewReasons,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -105,7 +106,6 @@ describe("Review Harness contract", () => {
|
||||
...preservedSyncSettings,
|
||||
syncMaxSizeInMB: NEW_VAULT_SETTINGS.syncMaxSizeInMB,
|
||||
chunkSplitterVersion: NEW_VAULT_SETTINGS.chunkSplitterVersion,
|
||||
doNotUseFixedRevisionForChunks: NEW_VAULT_SETTINGS.doNotUseFixedRevisionForChunks,
|
||||
usePluginSyncV2: NEW_VAULT_SETTINGS.usePluginSyncV2,
|
||||
handleFilenameCaseSensitive: NEW_VAULT_SETTINGS.handleFilenameCaseSensitive,
|
||||
E2EEAlgorithm: NEW_VAULT_SETTINGS.E2EEAlgorithm,
|
||||
|
||||
@@ -31,6 +31,7 @@ function compatibilityPause(): CompatibilityPause {
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons: migration().reviewReasons,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -473,7 +473,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
isNeedRebuildLocal() {
|
||||
return this.isSomeDirty([
|
||||
"useIndexedDBAdapter",
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
"handleFilenameCaseSensitive",
|
||||
"passphrase",
|
||||
"useDynamicIterationCount",
|
||||
@@ -484,7 +483,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
isNeedRebuildRemote() {
|
||||
return this.isSomeDirty([
|
||||
"doNotUseFixedRevisionForChunks",
|
||||
"handleFilenameCaseSensitive",
|
||||
"passphrase",
|
||||
"useDynamicIterationCount",
|
||||
|
||||
@@ -11,10 +11,13 @@ import {
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { request } from "@/deps.ts";
|
||||
import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
||||
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
|
||||
import {
|
||||
createCoreSettingsAfterFullReset,
|
||||
createEditingSettingsAfterFullReset,
|
||||
} from "@/serviceFeatures/setupObsidian/settingsReset.ts";
|
||||
export function paneSetup(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
@@ -92,9 +95,9 @@ export function paneSetup(
|
||||
{ defaultOption: "No" }
|
||||
)) == "yes"
|
||||
) {
|
||||
this.editingSettings = { ...this.editingSettings, ...DEFAULT_SETTINGS };
|
||||
this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings);
|
||||
await this.saveAllDirtySettings();
|
||||
this.core.settings = { ...DEFAULT_SETTINGS };
|
||||
this.core.settings = createCoreSettingsAfterFullReset();
|
||||
await this.services.setting.saveSettingData();
|
||||
await this.services.database.resetDatabase();
|
||||
// await this.plugin.initializeDatabase();
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
@@ -99,7 +99,7 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
*/
|
||||
async onOnboard(userMode: UserMode): Promise<boolean> {
|
||||
const originalSetting = userMode === UserMode.NewUser ? DEFAULT_SETTINGS : this.core.settings;
|
||||
const originalSetting = userMode === UserMode.NewUser ? createNewVaultSettings() : this.core.settings;
|
||||
if (userMode === UserMode.NewUser) {
|
||||
//Ask how to apply initial setup
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodNewUser);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
|
||||
@@ -125,6 +130,16 @@ describe("SetupManager", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
|
||||
const { manager, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
|
||||
const configureManually = vi.spyOn(manager, "onConfigureManually").mockResolvedValue(true);
|
||||
|
||||
await manager.onOnboard(UserMode.NewUser);
|
||||
|
||||
expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser);
|
||||
});
|
||||
|
||||
it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel
|
||||
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
evaluateCompatibilityPause,
|
||||
legacyDatabaseCompatibilityVersionKey,
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
|
||||
export type CompatibilityReviewSummaryAction =
|
||||
| "details"
|
||||
| "resume"
|
||||
| "use-case-sensitive"
|
||||
| "keep-paused"
|
||||
| false;
|
||||
export type CompatibilityReviewDetailsAction = "back" | false;
|
||||
|
||||
// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the
|
||||
@@ -89,16 +95,26 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async acknowledge(): Promise<void> {
|
||||
private async acknowledge(options: { useCaseSensitiveFilenames?: boolean } = {}): Promise<void> {
|
||||
if (!this.pause?.resumable) return;
|
||||
const setting = this.core.services.setting;
|
||||
const settings = setting.currentSettings();
|
||||
const previousMessage = settings.versionUpFlash;
|
||||
const hadFilenameCaseDecision = typeof settings.handleFilenameCaseSensitive === "boolean";
|
||||
const previousFilenameCaseDecision = settings.handleFilenameCaseSensitive;
|
||||
if (options.useCaseSensitiveFilenames) {
|
||||
settings.handleFilenameCaseSensitive = true;
|
||||
}
|
||||
settings.versionUpFlash = "";
|
||||
try {
|
||||
await setting.saveSettingData();
|
||||
} catch (error) {
|
||||
settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE;
|
||||
if (hadFilenameCaseDecision) {
|
||||
settings.handleFilenameCaseSensitive = previousFilenameCaseDecision;
|
||||
} else {
|
||||
delete (settings as Partial<typeof settings>).handleFilenameCaseSensitive;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -119,9 +135,15 @@ export class CompatibilityReviewController {
|
||||
break;
|
||||
}
|
||||
if (action === "resume" && this.pause.resumable) {
|
||||
if (requiresFilenameCaseSensitivityDecision(this.pause)) break;
|
||||
await this.acknowledge();
|
||||
return;
|
||||
}
|
||||
if (action === "use-case-sensitive" && this.pause.resumable) {
|
||||
if (!requiresFilenameCaseSensitivityDecision(this.pause)) break;
|
||||
await this.acknowledge({ useCaseSensitiveFilenames: true });
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (this.pause && !this.disposed) {
|
||||
|
||||
@@ -39,7 +39,10 @@ function createFixture(
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const settings = { versionUpFlash: options.versionUpFlash ?? "" };
|
||||
const settings: {
|
||||
versionUpFlash: string;
|
||||
handleFilenameCaseSensitive?: boolean;
|
||||
} = { versionUpFlash: options.versionUpFlash ?? "" };
|
||||
const saveSettingData = vi.fn().mockResolvedValue(undefined);
|
||||
const applySettings = vi.fn().mockResolvedValue(true);
|
||||
const setting = {
|
||||
@@ -106,6 +109,61 @@ describe("compatibility review controller", () => {
|
||||
expect(fixture.ui.clearReminder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an explicit legacy-compatible filename-case decision before resuming", async () => {
|
||||
const fixture = createFixture({
|
||||
marker: "12",
|
||||
migration: {
|
||||
sourceVersion: 10,
|
||||
targetVersion: 10,
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("use-case-sensitive" as never);
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.settings.handleFilenameCaseSensitive).toBe(true);
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.saveSettingData).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not let a generic resume action bypass an unresolved filename-case decision", async () => {
|
||||
const fixture = createFixture({
|
||||
marker: "12",
|
||||
migration: {
|
||||
sourceVersion: 10,
|
||||
targetVersion: 10,
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.settings.handleFilenameCaseSensitive).toBeUndefined();
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not allow a downgrade pause to be resumed", async () => {
|
||||
const fixture = createFixture({ marker: "13" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
|
||||
import {
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
type CompatibilityPauseReason,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export function compatibilityReviewSummaryMarkdown(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.";
|
||||
const action = !pause.resumable
|
||||
? "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again."
|
||||
: requiresFilenameCaseSensitivityDecision(pause)
|
||||
? "Before resuming, review the missing file-name case policy and explicitly keep the legacy-compatible behaviour, or leave synchronisation paused while you plan a database rebuild."
|
||||
: "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database.";
|
||||
return `Remote synchronisation is paused on this device because its compatibility state requires attention.
|
||||
|
||||
${action}
|
||||
@@ -28,6 +34,9 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
if (reason.isFromFutureSchema) {
|
||||
return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`;
|
||||
}
|
||||
if (reason.reviewReasons.some(({ code }) => code === "filename-case-sensitivity-unresolved")) {
|
||||
return "- This existing Vault has no saved file-name case policy. Self-hosted LiveSync will not infer a cross-platform policy while synchronisation is paused.";
|
||||
}
|
||||
return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`;
|
||||
}
|
||||
const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&");
|
||||
@@ -35,9 +44,11 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
}
|
||||
|
||||
export function compatibilityReviewDetailsMarkdown(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.";
|
||||
const resolution = !pause.resumable
|
||||
? "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation."
|
||||
: requiresFilenameCaseSensitivityDecision(pause)
|
||||
? "Choosing 'Keep case-sensitive handling and resume' records an explicit decision; case-sensitive handling preserves the earlier behaviour. To adopt cross-platform case-insensitive handling, keep synchronisation paused and use the compatibility setting workflow after checking for paths which differ only by letter case; case-insensitive handling requires a database rebuild."
|
||||
: "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.";
|
||||
return `## Why synchronisation is paused
|
||||
|
||||
${pause.reasons.map(reasonMarkdown).join("\n")}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Notice } from "@/deps.ts";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import {
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
import type {
|
||||
CompatibilityReviewDetailsAction,
|
||||
CompatibilityReviewSummaryAction,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
|
||||
const REVIEW_DETAILS = "Review compatibility details";
|
||||
const KEEP_PAUSED = "Keep synchronisation paused";
|
||||
const USE_CASE_SENSITIVE = "Keep case-sensitive handling and resume";
|
||||
const RESUME = "Resume synchronisation";
|
||||
const BACK = "Back to compatibility review";
|
||||
|
||||
@@ -22,9 +26,11 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
|
||||
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 buttons = !pause.resumable
|
||||
? ([REVIEW_DETAILS, KEEP_PAUSED] as const)
|
||||
: requiresFilenameCaseSensitivityDecision(pause)
|
||||
? ([REVIEW_DETAILS, USE_CASE_SENSITIVE, KEEP_PAUSED] as const)
|
||||
: ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const);
|
||||
const result = await this.confirm.confirmWithMessage(
|
||||
"Synchronisation paused for compatibility review",
|
||||
compatibilityReviewSummaryMarkdown(pause),
|
||||
@@ -34,6 +40,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
|
||||
"vertical"
|
||||
);
|
||||
if (result === REVIEW_DETAILS) return "details";
|
||||
if (result === USE_CASE_SENSITIVE) return "use-case-sensitive";
|
||||
if (result === RESUME) return "resume";
|
||||
if (result === KEEP_PAUSED) return "keep-paused";
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import { compatibilityReviewDetailsMarkdown } from "./compatibilityReviewMarkdown.ts";
|
||||
import { ObsidianCompatibilityReviewUi } from "./compatibilityReviewObsidian.ts";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Notice: class {
|
||||
hide() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const unresolvedFilenameCasePause: CompatibilityPause = {
|
||||
resumable: true,
|
||||
reasons: [
|
||||
{
|
||||
source: "settings-schema",
|
||||
sourceVersion: 10,
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("Obsidian compatibility review", () => {
|
||||
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
|
||||
@@ -21,4 +48,27 @@ describe("Obsidian compatibility review", () => {
|
||||
expect(details).toContain("new Obsidian profile");
|
||||
expect(details).toContain("does not mean that it is safe to resume automatically");
|
||||
});
|
||||
|
||||
it("explains the safe choices for an unresolved filename-case policy", () => {
|
||||
const details = compatibilityReviewDetailsMarkdown(unresolvedFilenameCasePause);
|
||||
expect(details).toContain("file-name case policy");
|
||||
expect(details).toContain("case-sensitive handling preserves the earlier behaviour");
|
||||
expect(details).toContain("case-insensitive handling requires a database rebuild");
|
||||
});
|
||||
|
||||
it("offers the legacy-compatible case decision instead of a generic resume action", async () => {
|
||||
const label = "Keep case-sensitive handling and resume";
|
||||
const confirmWithMessage = vi.fn().mockResolvedValue(label);
|
||||
const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never);
|
||||
|
||||
await expect(ui.showSummary(unresolvedFilenameCasePause)).resolves.toBe("use-case-sensitive");
|
||||
expect(confirmWithMessage).toHaveBeenCalledWith(
|
||||
"Synchronisation paused for compatibility review",
|
||||
expect.any(String),
|
||||
["Review compatibility details", label, "Keep synchronisation paused"],
|
||||
"Keep synchronisation paused",
|
||||
undefined,
|
||||
"vertical"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
export function createEditingSettingsAfterFullReset<T extends ObsidianLiveSyncSettings>(editingSettings: T): T {
|
||||
return { ...editingSettings, ...createNewVaultSettings(), isConfigured: false };
|
||||
}
|
||||
|
||||
export function createCoreSettingsAfterFullReset(): ObsidianLiveSyncSettings {
|
||||
return { ...createNewVaultSettings(), isConfigured: false };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createCoreSettingsAfterFullReset, createEditingSettingsAfterFullReset } from "./settingsReset.ts";
|
||||
|
||||
describe("full settings reset", () => {
|
||||
it("resets the persisted settings to the recommended new-Vault values", () => {
|
||||
const settings = createCoreSettingsAfterFullReset();
|
||||
expect(settings).toEqual({
|
||||
...createNewVaultSettings(),
|
||||
isConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves settings-dialog fields while applying the new-Vault values", () => {
|
||||
const editing = {
|
||||
...DEFAULT_SETTINGS,
|
||||
configPassphrase: "dialog-only",
|
||||
} as ObsidianLiveSyncSettings & { configPassphrase: string };
|
||||
|
||||
expect(createEditingSettingsAfterFullReset(editing)).toEqual({
|
||||
...editing,
|
||||
...createNewVaultSettings(),
|
||||
isConfigured: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ function compatibilityPause(): CompatibilityPause {
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user