diff --git a/docs/settings.md b/docs/settings.md index 7412aae9..e574fb64 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -853,10 +853,10 @@ Enable this option to automatically apply the most recent change to documents ev Setting key: useIndexedDBAdapter Before v0.17.16, we used an old adapter for the local database. Now the new adapter is preferred. However, it needs local database rebuilding. Please disable this toggle when you have enough time. If leave it enabled, also while fetching from the remote database, you will be asked to disable this. -#### Compute revisions for chunks (Previous behaviour) +#### Content-derived chunk revisions (obsolete setting) Setting key: doNotUseFixedRevisionForChunks -If this enabled, all chunks will be stored with the revision made from its content. (Previous behaviour) +Chunk revisions are always derived from their content. This key remains accepted in stored settings and Setup URIs for compatibility, but its value no longer changes behaviour and it is not a maintenance prerequisite. #### Handle files as Case-Sensitive @@ -865,6 +865,8 @@ If this enabled, All files are handled as case-Sensitive (Previous behaviour). When this setting is disabled, changing only the letter case of a file name within the same directory is synchronised as a rename. Changing the letter case of a directory name is not supported by this handling. +New Vaults use case-insensitive handling for cross-platform compatibility. Existing settings with an explicit value preserve that choice. If an existing Vault has no saved value, remote synchronisation remains paused until case-sensitive handling is explicitly retained. Changing such a Vault to case-insensitive handling requires checking for paths which differ only by letter case and rebuilding the database through the normal compatibility setting workflow. + ### 4. Compatibility (Internal API Usage) #### Scan changes on customisation sync diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 735afe6f..270b3823 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -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 Show discovered peers as [peer] p2p-sync Synchronise with specified peer-id or peer-name diff --git a/src/apps/cli/cliSettingsDefaults.ts b/src/apps/cli/cliSettingsDefaults.ts new file mode 100644 index 00000000..ec0cba61 --- /dev/null +++ b/src/apps/cli/cliSettingsDefaults.ts @@ -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, + }; +} diff --git a/src/apps/cli/cliSettingsDefaults.unit.spec.ts b/src/apps/cli/cliSettingsDefaults.unit.spec.ts new file mode 100644 index 00000000..2b7a7689 --- /dev/null +++ b/src/apps/cli/cliSettingsDefaults.unit.spec.ts @@ -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, + }); + }); +}); diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index e9f70a0d..b2126e03 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -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"); diff --git a/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh b/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh index 2756c4ad..6d6caf32 100644 --- a/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh +++ b/src/apps/cli/test/test-p2p-upload-download-repro-linux.sh @@ -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; diff --git a/src/apps/cli/testdeno/helpers/settings.ts b/src/apps/cli/testdeno/helpers/settings.ts index 179cfb0f..200a2443 100644 --- a/src/apps/cli/testdeno/helpers/settings.ts +++ b/src/apps/cli/testdeno/helpers/settings.ts @@ -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)); diff --git a/src/common/databaseCompatibility.ts b/src/common/databaseCompatibility.ts index 8c355edd..0cca8570 100644 --- a/src/common/databaseCompatibility.ts +++ b/src/common/databaseCompatibility.ts @@ -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, }); } diff --git a/src/common/databaseCompatibility.unit.spec.ts b/src/common/databaseCompatibility.unit.spec.ts index 6556da5e..a988dd3c 100644 --- a/src/common/databaseCompatibility.unit.spec.ts +++ b/src/common/databaseCompatibility.unit.spec.ts @@ -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", diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts index 7b6ca282..0432d637 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts @@ -3,8 +3,12 @@ import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites"; function createPrerequisites(settingsOverride: Partial = {}) { - 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 } 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(); + }); }); diff --git a/src/features/LocalDatabaseMainte/maintenancePrerequisites.ts b/src/features/LocalDatabaseMainte/maintenancePrerequisites.ts index f6935356..ae2cd929 100644 --- a/src/features/LocalDatabaseMainte/maintenancePrerequisites.ts +++ b/src/features/LocalDatabaseMainte/maintenancePrerequisites.ts @@ -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 { 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; diff --git a/src/features/ReviewHarness/reviewHarnessContract.ts b/src/features/ReviewHarness/reviewHarnessContract.ts index 7d7a8486..c9faa1e7 100644 --- a/src/features/ReviewHarness/reviewHarnessContract.ts +++ b/src/features/ReviewHarness/reviewHarnessContract.ts @@ -117,7 +117,6 @@ const PRESERVED_SYNC_SETTING_KEYS = [ const NEW_VAULT_RECOMMENDATION_KEYS = [ "syncMaxSizeInMB", "chunkSplitterVersion", - "doNotUseFixedRevisionForChunks", "usePluginSyncV2", "handleFilenameCaseSensitive", "E2EEAlgorithm", diff --git a/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts index d282b820..f10ebe38 100644 --- a/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessContract.unit.spec.ts @@ -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, diff --git a/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts index 65ffb88f..f80fa183 100644 --- a/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessController.unit.spec.ts @@ -31,6 +31,7 @@ function compatibilityPause(): CompatibilityPause { currentVersion: 10, isFromFutureSchema: false, resumable: true, + reviewReasons: migration().reviewReasons, }, ], }; diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 1cdeeb47..764b9756 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -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", diff --git a/src/modules/features/SettingDialogue/PaneSetup.ts b/src/modules/features/SettingDialogue/PaneSetup.ts index 1424587c..d9605ba2 100644 --- a/src/modules/features/SettingDialogue/PaneSetup.ts +++ b/src/modules/features/SettingDialogue/PaneSetup.ts @@ -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(); diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index fbb0a674..fc4ae05d 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -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 { - 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); diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index fa9f0b72..a676db23 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -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 diff --git a/src/serviceFeatures/compatibilityReview.ts b/src/serviceFeatures/compatibilityReview.ts index 66a3b67c..d4670c2e 100644 --- a/src/serviceFeatures/compatibilityReview.ts +++ b/src/serviceFeatures/compatibilityReview.ts @@ -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 { + private async acknowledge(options: { useCaseSensitiveFilenames?: boolean } = {}): Promise { 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).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) { diff --git a/src/serviceFeatures/compatibilityReview.unit.spec.ts b/src/serviceFeatures/compatibilityReview.unit.spec.ts index e5868bde..63b43d9b 100644 --- a/src/serviceFeatures/compatibilityReview.unit.spec.ts +++ b/src/serviceFeatures/compatibilityReview.unit.spec.ts @@ -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"); diff --git a/src/serviceFeatures/compatibilityReviewMarkdown.ts b/src/serviceFeatures/compatibilityReviewMarkdown.ts index eb427e31..e73cb544 100644 --- a/src/serviceFeatures/compatibilityReviewMarkdown.ts +++ b/src/serviceFeatures/compatibilityReviewMarkdown.ts @@ -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")} diff --git a/src/serviceFeatures/compatibilityReviewObsidian.ts b/src/serviceFeatures/compatibilityReviewObsidian.ts index adb349a7..2a33e47c 100644 --- a/src/serviceFeatures/compatibilityReviewObsidian.ts +++ b/src/serviceFeatures/compatibilityReviewObsidian.ts @@ -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 { - 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; diff --git a/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts b/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts index 6aaca855..c6547052 100644 --- a/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts +++ b/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts @@ -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" + ); + }); }); diff --git a/src/serviceFeatures/setupObsidian/settingsReset.ts b/src/serviceFeatures/setupObsidian/settingsReset.ts new file mode 100644 index 00000000..10862195 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/settingsReset.ts @@ -0,0 +1,10 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; + +export function createEditingSettingsAfterFullReset(editingSettings: T): T { + return { ...editingSettings, ...createNewVaultSettings(), isConfigured: false }; +} + +export function createCoreSettingsAfterFullReset(): ObsidianLiveSyncSettings { + return { ...createNewVaultSettings(), isConfigured: false }; +} diff --git a/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts b/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts new file mode 100644 index 00000000..4d0a5cf6 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts @@ -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, + }); + }); +}); diff --git a/src/serviceFeatures/useReviewHarness.unit.spec.ts b/src/serviceFeatures/useReviewHarness.unit.spec.ts index 1326185e..1b3f48e4 100644 --- a/src/serviceFeatures/useReviewHarness.unit.spec.ts +++ b/src/serviceFeatures/useReviewHarness.unit.spec.ts @@ -20,6 +20,7 @@ function compatibilityPause(): CompatibilityPause { currentVersion: 10, isFromFutureSchema: false, resumable: true, + reviewReasons: [], }, ], }; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index e56de655..ddf7be7f 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -81,7 +81,7 @@ npm run test:e2e:obsidian:local-suite:services `test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change. -`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault and verifies that startup offers the setup wizard without opening it or scanning Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. +`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises two representative Svelte dialogue routes: remote server selection through `SetupManager`, and Setup URI entry through the registered command. The session pre-seeds a configured, inactive plug-in state so that onboarding and migration prompts do not interfere with the dialogues under test. It requires each dialogue title and its principal controls to be visible, captures desktop and mobile screenshots, closes them through their normal user controls, and verifies that the remote-selection promise settles without an error. This covers the host, context, component, and result boundaries moved during the Commonlib package extraction without applying settings or contacting a remote service. diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts index 972ac32a..82818632 100644 --- a/test/e2e-obsidian/scripts/onboarding-invitation.ts +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -19,6 +19,10 @@ type UnconfiguredStartupEvidence = { configured: boolean; markerInDatabase: boolean; offlineScanInitialised: boolean; + recommendedDefaults: { + usePluginSyncV2: boolean; + handleFilenameCaseSensitive: boolean; + }; }; type ObsidianTestApp = { @@ -47,10 +51,15 @@ async function inspectUnconfiguredStartup( "try{entry=await core.localDatabase.getDBEntry(markerPath,undefined,false,false);}catch{}", "let initialised=false;", "try{initialised=(await core.kvDB.get('initialized'))===true;}catch{}", + "const settings=core.services.setting.currentSettings();", "return JSON.stringify({", - "configured:core.services.setting.currentSettings()?.isConfigured===true,", + "configured:settings?.isConfigured===true,", "markerInDatabase:Boolean(entry&&entry._id),", "offlineScanInitialised:initialised,", + "recommendedDefaults:{", + "usePluginSyncV2:settings?.usePluginSyncV2,", + "handleFilenameCaseSensitive:settings?.handleFilenameCaseSensitive,", + "},", "});", "})()", ].join(""), @@ -167,12 +176,18 @@ async function main(): Promise { startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), }); - const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv); - console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`); await requireInvitationWithoutDialogue(); - if (evidence.configured || evidence.markerInDatabase || evidence.offlineScanInitialised) { - throw new Error(`Unconfigured startup performed ordinary processing: ${JSON.stringify(evidence)}`); + const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv); + if ( + evidence.configured || + evidence.markerInDatabase || + evidence.offlineScanInitialised || + evidence.recommendedDefaults.usePluginSyncV2 !== true || + evidence.recommendedDefaults.handleFilenameCaseSensitive !== false + ) { + throw new Error(`Fresh Vault startup state did not match its contract: ${JSON.stringify(evidence)}`); } + console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`); const desktopInvitation = await captureDesktopInvitation(); await openPermanentCommand();