mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
Align host settings with the 1.0 lifecycle
This commit is contained in:
+4
-2
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user