mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-25 05:53:00 +00:00
Align host settings with the 1.0 lifecycle
This commit is contained in:
@@ -6,10 +6,16 @@ import {
|
||||
DATABASE_COMPATIBILITY_VERSION_KEY,
|
||||
evaluateCompatibilityPause,
|
||||
legacyDatabaseCompatibilityVersionKey,
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false;
|
||||
export type CompatibilityReviewSummaryAction =
|
||||
| "details"
|
||||
| "resume"
|
||||
| "use-case-sensitive"
|
||||
| "keep-paused"
|
||||
| false;
|
||||
export type CompatibilityReviewDetailsAction = "back" | false;
|
||||
|
||||
// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the
|
||||
@@ -89,16 +95,26 @@ export class CompatibilityReviewController {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async acknowledge(): Promise<void> {
|
||||
private async acknowledge(options: { useCaseSensitiveFilenames?: boolean } = {}): Promise<void> {
|
||||
if (!this.pause?.resumable) return;
|
||||
const setting = this.core.services.setting;
|
||||
const settings = setting.currentSettings();
|
||||
const previousMessage = settings.versionUpFlash;
|
||||
const hadFilenameCaseDecision = typeof settings.handleFilenameCaseSensitive === "boolean";
|
||||
const previousFilenameCaseDecision = settings.handleFilenameCaseSensitive;
|
||||
if (options.useCaseSensitiveFilenames) {
|
||||
settings.handleFilenameCaseSensitive = true;
|
||||
}
|
||||
settings.versionUpFlash = "";
|
||||
try {
|
||||
await setting.saveSettingData();
|
||||
} catch (error) {
|
||||
settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE;
|
||||
if (hadFilenameCaseDecision) {
|
||||
settings.handleFilenameCaseSensitive = previousFilenameCaseDecision;
|
||||
} else {
|
||||
delete (settings as Partial<typeof settings>).handleFilenameCaseSensitive;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -119,9 +135,15 @@ export class CompatibilityReviewController {
|
||||
break;
|
||||
}
|
||||
if (action === "resume" && this.pause.resumable) {
|
||||
if (requiresFilenameCaseSensitivityDecision(this.pause)) break;
|
||||
await this.acknowledge();
|
||||
return;
|
||||
}
|
||||
if (action === "use-case-sensitive" && this.pause.resumable) {
|
||||
if (!requiresFilenameCaseSensitivityDecision(this.pause)) break;
|
||||
await this.acknowledge({ useCaseSensitiveFilenames: true });
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (this.pause && !this.disposed) {
|
||||
|
||||
@@ -39,7 +39,10 @@ function createFixture(
|
||||
if (options.legacyMarker !== undefined && options.legacyMarker !== null) {
|
||||
local.set(legacyKey, options.legacyMarker);
|
||||
}
|
||||
const settings = { versionUpFlash: options.versionUpFlash ?? "" };
|
||||
const settings: {
|
||||
versionUpFlash: string;
|
||||
handleFilenameCaseSensitive?: boolean;
|
||||
} = { versionUpFlash: options.versionUpFlash ?? "" };
|
||||
const saveSettingData = vi.fn().mockResolvedValue(undefined);
|
||||
const applySettings = vi.fn().mockResolvedValue(true);
|
||||
const setting = {
|
||||
@@ -106,6 +109,61 @@ describe("compatibility review controller", () => {
|
||||
expect(fixture.ui.clearReminder).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an explicit legacy-compatible filename-case decision before resuming", async () => {
|
||||
const fixture = createFixture({
|
||||
marker: "12",
|
||||
migration: {
|
||||
sourceVersion: 10,
|
||||
targetVersion: 10,
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("use-case-sensitive" as never);
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.settings.handleFilenameCaseSensitive).toBe(true);
|
||||
expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12");
|
||||
expect(fixture.saveSettingData).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.applySettings).toHaveBeenCalledOnce();
|
||||
expect(fixture.controller.pendingPause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not let a generic resume action bypass an unresolved filename-case decision", async () => {
|
||||
const fixture = createFixture({
|
||||
marker: "12",
|
||||
migration: {
|
||||
sourceVersion: 10,
|
||||
targetVersion: 10,
|
||||
requiresSyncReview: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
await fixture.controller.initialise();
|
||||
await fixture.controller.openReview();
|
||||
|
||||
expect(fixture.settings.handleFilenameCaseSensitive).toBeUndefined();
|
||||
expect(fixture.applySettings).not.toHaveBeenCalled();
|
||||
expect(fixture.controller.pendingPause).toBeDefined();
|
||||
expect(fixture.ui.showReminder).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not allow a downgrade pause to be resumed", async () => {
|
||||
const fixture = createFixture({ marker: "13" });
|
||||
vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume");
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts";
|
||||
import {
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
type CompatibilityPauseReason,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
|
||||
export function compatibilityReviewSummaryMarkdown(pause: CompatibilityPause): string {
|
||||
const action = pause.resumable
|
||||
? "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database."
|
||||
: "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again.";
|
||||
const action = !pause.resumable
|
||||
? "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again."
|
||||
: requiresFilenameCaseSensitivityDecision(pause)
|
||||
? "Before resuming, review the missing file-name case policy and explicitly keep the legacy-compatible behaviour, or leave synchronisation paused while you plan a database rebuild."
|
||||
: "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database.";
|
||||
return `Remote synchronisation is paused on this device because its compatibility state requires attention.
|
||||
|
||||
${action}
|
||||
@@ -28,6 +34,9 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
if (reason.isFromFutureSchema) {
|
||||
return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`;
|
||||
}
|
||||
if (reason.reviewReasons.some(({ code }) => code === "filename-case-sensitivity-unresolved")) {
|
||||
return "- This existing Vault has no saved file-name case policy. Self-hosted LiveSync will not infer a cross-platform policy while synchronisation is paused.";
|
||||
}
|
||||
return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`;
|
||||
}
|
||||
const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&");
|
||||
@@ -35,9 +44,11 @@ function reasonMarkdown(reason: CompatibilityPauseReason): string {
|
||||
}
|
||||
|
||||
export function compatibilityReviewDetailsMarkdown(pause: CompatibilityPause): string {
|
||||
const resolution = pause.resumable
|
||||
? "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged."
|
||||
: "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation.";
|
||||
const resolution = !pause.resumable
|
||||
? "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation."
|
||||
: requiresFilenameCaseSensitivityDecision(pause)
|
||||
? "Choosing 'Keep case-sensitive handling and resume' records an explicit decision; case-sensitive handling preserves the earlier behaviour. To adopt cross-platform case-insensitive handling, keep synchronisation paused and use the compatibility setting workflow after checking for paths which differ only by letter case; case-insensitive handling requires a database rebuild."
|
||||
: "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged.";
|
||||
return `## Why synchronisation is paused
|
||||
|
||||
${pause.reasons.map(reasonMarkdown).join("\n")}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Notice } from "@/deps.ts";
|
||||
import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import {
|
||||
requiresFilenameCaseSensitivityDecision,
|
||||
type CompatibilityPause,
|
||||
} from "@/common/databaseCompatibility.ts";
|
||||
import type {
|
||||
CompatibilityReviewDetailsAction,
|
||||
CompatibilityReviewSummaryAction,
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
|
||||
const REVIEW_DETAILS = "Review compatibility details";
|
||||
const KEEP_PAUSED = "Keep synchronisation paused";
|
||||
const USE_CASE_SENSITIVE = "Keep case-sensitive handling and resume";
|
||||
const RESUME = "Resume synchronisation";
|
||||
const BACK = "Back to compatibility review";
|
||||
|
||||
@@ -22,9 +26,11 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
|
||||
constructor(private readonly confirm: Confirm) {}
|
||||
|
||||
async showSummary(pause: CompatibilityPause): Promise<CompatibilityReviewSummaryAction> {
|
||||
const buttons = pause.resumable
|
||||
? ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const)
|
||||
: ([REVIEW_DETAILS, KEEP_PAUSED] as const);
|
||||
const buttons = !pause.resumable
|
||||
? ([REVIEW_DETAILS, KEEP_PAUSED] as const)
|
||||
: requiresFilenameCaseSensitivityDecision(pause)
|
||||
? ([REVIEW_DETAILS, USE_CASE_SENSITIVE, KEEP_PAUSED] as const)
|
||||
: ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const);
|
||||
const result = await this.confirm.confirmWithMessage(
|
||||
"Synchronisation paused for compatibility review",
|
||||
compatibilityReviewSummaryMarkdown(pause),
|
||||
@@ -34,6 +40,7 @@ export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi {
|
||||
"vertical"
|
||||
);
|
||||
if (result === REVIEW_DETAILS) return "details";
|
||||
if (result === USE_CASE_SENSITIVE) return "use-case-sensitive";
|
||||
if (result === RESUME) return "resume";
|
||||
if (result === KEEP_PAUSED) return "keep-paused";
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CompatibilityPause } from "@/common/databaseCompatibility.ts";
|
||||
import { compatibilityReviewDetailsMarkdown } from "./compatibilityReviewMarkdown.ts";
|
||||
import { ObsidianCompatibilityReviewUi } from "./compatibilityReviewObsidian.ts";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Notice: class {
|
||||
hide() {}
|
||||
},
|
||||
}));
|
||||
|
||||
const unresolvedFilenameCasePause: CompatibilityPause = {
|
||||
resumable: true,
|
||||
reasons: [
|
||||
{
|
||||
source: "settings-schema",
|
||||
sourceVersion: 10,
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons: [
|
||||
{
|
||||
code: "filename-case-sensitivity-unresolved",
|
||||
fromVersion: 10,
|
||||
toVersion: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("Obsidian compatibility review", () => {
|
||||
it("explains why a configured Vault can be missing its device-local acknowledgement", async () => {
|
||||
@@ -21,4 +48,27 @@ describe("Obsidian compatibility review", () => {
|
||||
expect(details).toContain("new Obsidian profile");
|
||||
expect(details).toContain("does not mean that it is safe to resume automatically");
|
||||
});
|
||||
|
||||
it("explains the safe choices for an unresolved filename-case policy", () => {
|
||||
const details = compatibilityReviewDetailsMarkdown(unresolvedFilenameCasePause);
|
||||
expect(details).toContain("file-name case policy");
|
||||
expect(details).toContain("case-sensitive handling preserves the earlier behaviour");
|
||||
expect(details).toContain("case-insensitive handling requires a database rebuild");
|
||||
});
|
||||
|
||||
it("offers the legacy-compatible case decision instead of a generic resume action", async () => {
|
||||
const label = "Keep case-sensitive handling and resume";
|
||||
const confirmWithMessage = vi.fn().mockResolvedValue(label);
|
||||
const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never);
|
||||
|
||||
await expect(ui.showSummary(unresolvedFilenameCasePause)).resolves.toBe("use-case-sensitive");
|
||||
expect(confirmWithMessage).toHaveBeenCalledWith(
|
||||
"Synchronisation paused for compatibility review",
|
||||
expect.any(String),
|
||||
["Review compatibility details", label, "Keep synchronisation paused"],
|
||||
"Keep synchronisation paused",
|
||||
undefined,
|
||||
"vertical"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
export function createEditingSettingsAfterFullReset<T extends ObsidianLiveSyncSettings>(editingSettings: T): T {
|
||||
return { ...editingSettings, ...createNewVaultSettings(), isConfigured: false };
|
||||
}
|
||||
|
||||
export function createCoreSettingsAfterFullReset(): ObsidianLiveSyncSettings {
|
||||
return { ...createNewVaultSettings(), isConfigured: false };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { createCoreSettingsAfterFullReset, createEditingSettingsAfterFullReset } from "./settingsReset.ts";
|
||||
|
||||
describe("full settings reset", () => {
|
||||
it("resets the persisted settings to the recommended new-Vault values", () => {
|
||||
const settings = createCoreSettingsAfterFullReset();
|
||||
expect(settings).toEqual({
|
||||
...createNewVaultSettings(),
|
||||
isConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves settings-dialog fields while applying the new-Vault values", () => {
|
||||
const editing = {
|
||||
...DEFAULT_SETTINGS,
|
||||
configPassphrase: "dialog-only",
|
||||
} as ObsidianLiveSyncSettings & { configPassphrase: string };
|
||||
|
||||
expect(createEditingSettingsAfterFullReset(editing)).toEqual({
|
||||
...editing,
|
||||
...createNewVaultSettings(),
|
||||
isConfigured: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ function compatibilityPause(): CompatibilityPause {
|
||||
currentVersion: 10,
|
||||
isFromFutureSchema: false,
|
||||
resumable: true,
|
||||
reviewReasons: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user