mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Preserve legacy settings across same-profile upgrades
This commit is contained in:
@@ -55,16 +55,6 @@ 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,
|
||||
|
||||
@@ -109,11 +109,11 @@ describe("database compatibility evaluation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains an unresolved filename-case decision in the host compatibility reason", () => {
|
||||
it("retains a settings migration review in the host compatibility reason", () => {
|
||||
const reviewReasons = [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
code: "legacy-update-review-pending",
|
||||
fromVersion: 9,
|
||||
toVersion: 10,
|
||||
},
|
||||
];
|
||||
@@ -121,7 +121,7 @@ describe("database compatibility evaluation", () => {
|
||||
acknowledgedVersion: "12",
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState({
|
||||
sourceVersion: 10,
|
||||
sourceVersion: 9,
|
||||
targetVersion: 10,
|
||||
requiresSyncReview: true,
|
||||
reviewReasons,
|
||||
@@ -131,7 +131,7 @@ describe("database compatibility evaluation", () => {
|
||||
|
||||
expect(result.pause?.reasons).toContainEqual({
|
||||
source: "settings-schema",
|
||||
sourceVersion: 10,
|
||||
sourceVersion: 9,
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
|
||||
@@ -6,16 +6,10 @@ import {
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
evaluateCompatibilityPause,
|
||||
legacyDatabaseCompatibilityVersionKey,
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export type CompatibilityReviewSummaryAction =
|
||||
| "details"
|
||||
| "resume"
|
||||
| "use-case-sensitive"
|
||||
| "keep-paused"
|
||||
| false;
|
||||
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
|
||||
export type CompatibilityReviewDetailsAction = "back" | false;
|
||||
|
||||
// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the
|
||||
@@ -68,11 +62,25 @@ export class CompatibilityReviewController {
|
||||
if (this.disposed) return true;
|
||||
const setting = this.core.services.setting;
|
||||
const settings = setting.currentSettings();
|
||||
const migrationState = setting.getSettingsMigrationState();
|
||||
|
||||
// An existing unconfigured Vault cannot replicate, so a database
|
||||
// compatibility pause would only compete with onboarding and persist
|
||||
// a misleading sync warning. Do not acknowledge the missing marker:
|
||||
// activation on a later start must evaluate the same state again.
|
||||
// Genuinely new Vaults still initialise their marker below.
|
||||
if (settings.isConfigured !== true && migrationState?.isNewVault !== true) {
|
||||
this.pause = undefined;
|
||||
this.ui.clearReminder();
|
||||
this._initialised = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const acknowledgedVersion = this.readAcknowledgedVersion();
|
||||
const evaluation = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: this.currentVersion,
|
||||
migrationState: setting.getSettingsMigrationState(),
|
||||
migrationState,
|
||||
legacyReviewMessage: settings.versionUpFlash,
|
||||
});
|
||||
|
||||
@@ -95,26 +103,16 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async acknowledge(options: { useCaseSensitiveFilenames?: boolean } = {}): Promise<void> {
|
||||
private async acknowledge(): 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;
|
||||
}
|
||||
|
||||
@@ -135,15 +133,9 @@ 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) {
|
||||
|
||||
@@ -28,6 +28,7 @@ function createFixture(
|
||||
marker?: string | null;
|
||||
legacyMarker?: string | null;
|
||||
versionUpFlash?: string;
|
||||
isConfigured?: boolean;
|
||||
migration?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
@@ -39,10 +40,10 @@ function createFixture(
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const settings: {
|
||||
versionUpFlash: string;
|
||||
handleFilenameCaseSensitive?: boolean;
|
||||
} = { versionUpFlash: options.versionUpFlash ?? "" };
|
||||
const settings = {
|
||||
versionUpFlash: options.versionUpFlash ?? "",
|
||||
isConfigured: options.isConfigured ?? true,
|
||||
};
|
||||
const saveSettingData = vi.fn().mockResolvedValue(undefined);
|
||||
const applySettings = vi.fn().mockResolvedValue(true);
|
||||
const setting = {
|
||||
@@ -77,7 +78,7 @@ describe("compatibility review controller", () => {
|
||||
});
|
||||
|
||||
it("initialises the acknowledged version for a new Vault without showing a pause", async () => {
|
||||
const fixture = createFixture({ marker: null, migration: { isNewVault: true } });
|
||||
const fixture = createFixture({ marker: null, isConfigured: false, migration: { isNewVault: true } });
|
||||
|
||||
expect(fixture.controller.initialised).toBe(false);
|
||||
|
||||
@@ -89,6 +90,30 @@ describe("compatibility review controller", () => {
|
||||
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defers a missing database marker while the Vault remains unconfigured", async () => {
|
||||
const fixture = createFixture({ marker: null, isConfigured: false });
|
||||
|
||||
await expect(fixture.controller.initialise()).resolves.toBe(true);
|
||||
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
expect(fixture.settings.versionUpFlash).toBe("");
|
||||
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
|
||||
expect(fixture.saveSettingData).not.toHaveBeenCalled();
|
||||
|
||||
fixture.settings.isConfigured = true;
|
||||
await expect(fixture.controller.initialise()).resolves.toBe(true);
|
||||
|
||||
expect(fixture.controller.pendingPause?.reasons).toContainEqual({
|
||||
source: "database-version",
|
||||
state: "missing",
|
||||
currentVersion: 12,
|
||||
resumable: true,
|
||||
});
|
||||
expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE);
|
||||
expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false);
|
||||
expect(fixture.saveSettingData).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => {
|
||||
const fixture = createFixture({ marker: "11" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
@@ -109,61 +134,6 @@ 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,15 +1,9 @@
|
||||
import {
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
type CompatibilityPauseReason,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export function compatibilityReviewSummaryMarkdown(pause: CompatibilityPause): string {
|
||||
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.";
|
||||
: "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}
|
||||
@@ -34,9 +28,6 @@ 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, "\\$&");
|
||||
@@ -46,9 +37,7 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
export function compatibilityReviewDetailsMarkdown(pause: CompatibilityPause): string {
|
||||
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.";
|
||||
: "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,9 +1,6 @@
|
||||
import { Notice } from "@/deps.ts";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import {
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import type {
|
||||
CompatibilityReviewDetailsAction,
|
||||
CompatibilityReviewSummaryAction,
|
||||
@@ -16,7 +13,6 @@ 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";
|
||||
|
||||
@@ -28,9 +24,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
|
||||
async showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction> {
|
||||
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);
|
||||
: ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const);
|
||||
const result = await this.confirm.confirmWithMessage(
|
||||
"Synchronisation paused for compatibility review",
|
||||
compatibilityReviewSummaryMarkdown(pause),
|
||||
@@ -40,7 +34,6 @@ 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;
|
||||
|
||||
@@ -9,22 +9,15 @@ vi.mock("@/deps.ts", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const unresolvedFilenameCasePause: CompatibilityPause = {
|
||||
const resumablePause: CompatibilityPause = {
|
||||
resumable: true,
|
||||
reasons: [
|
||||
{
|
||||
source: "settings-schema",
|
||||
sourceVersion: 10,
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
source: "database-version",
|
||||
state: "upgrade",
|
||||
acknowledgedVersion: 11,
|
||||
currentVersion: 12,
|
||||
resumable: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -49,23 +42,15 @@ describe("Obsidian compatibility review", () => {
|
||||
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);
|
||||
it("offers the generic resume action in a vertical action dialogue", async () => {
|
||||
const confirmWithMessage = vi.fn().mockResolvedValue("Resume synchronisation");
|
||||
const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never);
|
||||
|
||||
await expect(ui.showSummary(unresolvedFilenameCasePause)).resolves.toBe("use-case-sensitive");
|
||||
await expect(ui.showSummary(resumablePause)).resolves.toBe("resume");
|
||||
expect(confirmWithMessage).toHaveBeenCalledWith(
|
||||
"Synchronisation paused for compatibility review",
|
||||
expect.any(String),
|
||||
["Review compatibility details", label, "Keep synchronisation paused"],
|
||||
["Review compatibility details", "Resume synchronisation", "Keep synchronisation paused"],
|
||||
"Keep synchronisation paused",
|
||||
undefined,
|
||||
"vertical"
|
||||
|
||||
Reference in New Issue
Block a user