mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
feat: restore opt-in first-run onboarding
This commit is contained in:
@@ -26,6 +26,11 @@ import {
|
||||
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { showOnboardingInvitation } from "@/serviceFeatures/setupObsidian/setupManagerHandlers.ts";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
} from "@/serviceFeatures/configuredStartupLifecycle.ts";
|
||||
|
||||
type ErrorInfo = {
|
||||
path: string;
|
||||
@@ -80,9 +85,9 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
}
|
||||
}
|
||||
|
||||
async initialMessage() {
|
||||
initialMessage() {
|
||||
const manager = this.core.getModule(SetupManager);
|
||||
return await manager.startOnBoarding();
|
||||
showOnboardingInvitation(this.core, manager);
|
||||
/*
|
||||
const message = $msg("moduleMigration.msgInitialSetup", {
|
||||
URI_DOC: $msg("moduleMigration.docUri"),
|
||||
@@ -344,39 +349,21 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
}
|
||||
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
if (!this.localDatabase.isReady) {
|
||||
this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (this.settings.isConfigured) {
|
||||
if (!(await this.hasCompromisedChunks())) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.hasIncompleteDocs())) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.migrateUsingDoctor(false))) {
|
||||
return false;
|
||||
}
|
||||
// await this.migrationCheck();
|
||||
await this.migrateDisableBulkSend();
|
||||
}
|
||||
if (!this.settings.isConfigured) {
|
||||
// if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
|
||||
// this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
// return false;
|
||||
// }
|
||||
if (!(await this.initialMessage())) {
|
||||
this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.migrateUsingDoctor(true))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return await runConfiguredStartupLifecycle({
|
||||
databaseReady: this.localDatabase.isReady,
|
||||
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
|
||||
hasCompromisedChunks: () => this.hasCompromisedChunks(),
|
||||
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
|
||||
runDoctor: () => this.migrateUsingDoctor(false),
|
||||
migrateBulkSend: () => this.migrateDisableBulkSend(),
|
||||
});
|
||||
}
|
||||
_everyOnLayoutReady(): Promise<boolean> {
|
||||
const shouldInitialiseDatabase = runStartupEntryLifecycle({
|
||||
configured: this.settings.isConfigured === true,
|
||||
inviteToOnboarding: () => this.initialMessage(),
|
||||
});
|
||||
if (!shouldInitialiseDatabase) return Promise.resolve(false);
|
||||
eventHub.onEvent(EVENT_REQUEST_RUN_DOCTOR, async (reason) => {
|
||||
await this.migrateUsingDoctor(false, reason, true);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import {
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||
EVENT_SETTING_SAVED,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
SettingService,
|
||||
type SettingServiceDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
|
||||
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
|
||||
|
||||
export function normaliseObsidianSettingsData(
|
||||
data: ObsidianLiveSyncSettings | null | undefined
|
||||
): ObsidianLiveSyncSettings | undefined {
|
||||
return data ?? undefined;
|
||||
}
|
||||
|
||||
export class ObsidianSettingService<T extends ObsidianServiceContext> extends SettingService<T> {
|
||||
constructor(context: T, dependencies: SettingServiceDependencies) {
|
||||
super(context, dependencies);
|
||||
@@ -33,6 +45,6 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
}
|
||||
protected override async loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
|
||||
return await this.context.liveSyncPlugin.loadData();
|
||||
return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts";
|
||||
|
||||
describe("normaliseObsidianSettingsData", () => {
|
||||
it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => {
|
||||
expect(normaliseObsidianSettingsData(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves stored settings", () => {
|
||||
const settings = { isConfigured: false } as ObsidianLiveSyncSettings;
|
||||
|
||||
expect(normaliseObsidianSettingsData(settings)).toBe(settings);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface ConfiguredStartupLifecycleRuntime {
|
||||
databaseReady: boolean;
|
||||
reportDatabaseNotReady(): void;
|
||||
hasCompromisedChunks(): Promise<boolean>;
|
||||
hasIncompleteDocuments(): Promise<boolean>;
|
||||
runDoctor(): Promise<boolean>;
|
||||
migrateBulkSend(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartupEntryLifecycleRuntime {
|
||||
configured: boolean;
|
||||
inviteToOnboarding(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps an unconfigured Vault outside database initialisation and all
|
||||
* configured-only start-up work while offering an explicit setup action.
|
||||
*/
|
||||
export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean {
|
||||
if (runtime.configured) return true;
|
||||
runtime.inviteToOnboarding();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates the inert, unconfigured startup path from checks which must run
|
||||
* before an already configured device is allowed to synchronise.
|
||||
*/
|
||||
export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise<boolean> {
|
||||
if (!runtime.databaseReady) {
|
||||
runtime.reportDatabaseNotReady();
|
||||
return false;
|
||||
}
|
||||
if (!(await runtime.hasCompromisedChunks())) return false;
|
||||
if (!(await runtime.hasIncompleteDocuments())) return false;
|
||||
if (!(await runtime.runDoctor())) return false;
|
||||
await runtime.migrateBulkSend();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runConfiguredStartupLifecycle,
|
||||
runStartupEntryLifecycle,
|
||||
type ConfiguredStartupLifecycleRuntime,
|
||||
} from "./configuredStartupLifecycle";
|
||||
|
||||
function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } {
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
databaseReady: true,
|
||||
reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")),
|
||||
hasCompromisedChunks: vi.fn(async () => {
|
||||
events.push("compromised-chunks");
|
||||
return true;
|
||||
}),
|
||||
hasIncompleteDocuments: vi.fn(async () => {
|
||||
events.push("incomplete-documents");
|
||||
return true;
|
||||
}),
|
||||
runDoctor: vi.fn(async () => {
|
||||
events.push("doctor");
|
||||
return true;
|
||||
}),
|
||||
migrateBulkSend: vi.fn(async () => {
|
||||
events.push("bulk-send");
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runConfiguredStartupLifecycle", () => {
|
||||
it("runs configured checks in order before allowing initialisation", async () => {
|
||||
const runtime = createRuntime();
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
|
||||
});
|
||||
|
||||
it("stops before onboarding or checks when the database is unavailable", async () => {
|
||||
const runtime = createRuntime();
|
||||
runtime.databaseReady = false;
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["database-not-ready"]);
|
||||
});
|
||||
|
||||
it("stops the configured sequence at the first failed check", async () => {
|
||||
const runtime = createRuntime();
|
||||
vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => {
|
||||
runtime.events.push("incomplete-documents");
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false);
|
||||
|
||||
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runStartupEntryLifecycle", () => {
|
||||
it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: false,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(inviteToOnboarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows a configured Vault to continue to database initialisation", () => {
|
||||
const inviteToOnboarding = vi.fn();
|
||||
|
||||
expect(
|
||||
runStartupEntryLifecycle({
|
||||
configured: true,
|
||||
inviteToOnboarding,
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(inviteToOnboarding).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,38 @@
|
||||
import { type SetupManager, UserMode } from "@/modules/features/SetupManager";
|
||||
import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types";
|
||||
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
|
||||
|
||||
const ONBOARDING_NOTICE_DURATION_MS = 60_000;
|
||||
|
||||
export async function openOnboarding(setupManager: SetupManager) {
|
||||
return await setupManager.startOnBoarding();
|
||||
}
|
||||
|
||||
export function showOnboardingInvitation(host: NecessaryServices<"UI", never>, setupManager: SetupManager): void {
|
||||
const message = `${$msg("Welcome to Self-hosted LiveSync")} ${$msg(
|
||||
"We will now guide you through a few questions to simplify the synchronisation setup."
|
||||
)} {HERE}`;
|
||||
host.services.UI.confirm.askInPopup(
|
||||
"initial-onboarding",
|
||||
message,
|
||||
(anchor) => {
|
||||
anchor.href = "#";
|
||||
anchor.classList.add("sls-onboarding-invitation-action");
|
||||
anchor.textContent = $msg("Ui.SetupWizard.Invitation.Start");
|
||||
anchor.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
fireAndForget(() => openOnboarding(setupManager));
|
||||
});
|
||||
},
|
||||
ONBOARDING_NOTICE_DURATION_MS
|
||||
);
|
||||
}
|
||||
|
||||
export async function openSetupURI(setupManager: SetupManager) {
|
||||
await setupManager.onUseSetupURI(UserMode.Unknown);
|
||||
@@ -17,6 +47,11 @@ export function useSetupManagerHandlersFeature(
|
||||
setupManager: SetupManager
|
||||
) {
|
||||
host.services.appLifecycle.onLoaded.addHandler(() => {
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-open-onboarding",
|
||||
name: "Open onboarding wizard",
|
||||
callback: () => fireAndForget(() => openOnboarding(setupManager)),
|
||||
});
|
||||
host.services.API.addCommand({
|
||||
id: "livesync-opensetupuri",
|
||||
name: "Use the copied setup URI (Formerly Open setup URI)",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers";
|
||||
import {
|
||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
|
||||
import {
|
||||
openOnboarding,
|
||||
openP2PSettings,
|
||||
openSetupURI,
|
||||
showOnboardingInvitation,
|
||||
useSetupManagerHandlersFeature,
|
||||
} from "./setupManagerHandlers";
|
||||
|
||||
vi.mock("@/modules/features/SetupManager", () => {
|
||||
return {
|
||||
@@ -43,6 +52,63 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
expect(setupManager.onP2PManualSetup).toHaveBeenCalledWith("unknown", settings, false);
|
||||
});
|
||||
|
||||
it("openOnboarding should delegate to SetupManager.startOnBoarding", async () => {
|
||||
const setupManager = {
|
||||
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
|
||||
} as any;
|
||||
|
||||
await openOnboarding(setupManager);
|
||||
|
||||
expect(setupManager.startOnBoarding).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("showOnboardingInvitation should wait for its fixed action link before opening onboarding", async () => {
|
||||
let configureAnchor: ((anchor: HTMLAnchorElement) => void) | undefined;
|
||||
const askInPopup = vi.fn(
|
||||
(_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void, _durationMs?: number) => {
|
||||
configureAnchor = callback;
|
||||
}
|
||||
);
|
||||
const host = {
|
||||
services: {
|
||||
UI: { confirm: { askInPopup } },
|
||||
},
|
||||
} as any;
|
||||
const setupManager = {
|
||||
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
|
||||
} as any;
|
||||
|
||||
showOnboardingInvitation(host, setupManager);
|
||||
|
||||
expect(setupManager.startOnBoarding).not.toHaveBeenCalled();
|
||||
expect(askInPopup).toHaveBeenCalledWith(
|
||||
"initial-onboarding",
|
||||
expect.stringContaining("{HERE}"),
|
||||
expect.any(Function),
|
||||
60_000
|
||||
);
|
||||
|
||||
let click: ((event: { preventDefault(): void }) => void) | undefined;
|
||||
const addClass = vi.fn();
|
||||
const anchor = {
|
||||
href: "",
|
||||
textContent: "",
|
||||
classList: { add: addClass },
|
||||
addEventListener: vi.fn((_name: string, listener: typeof click) => {
|
||||
click = listener;
|
||||
}),
|
||||
} as unknown as HTMLAnchorElement;
|
||||
configureAnchor!(anchor);
|
||||
|
||||
expect(anchor.href).toBe("#");
|
||||
expect(anchor.textContent).toBe("Start setup");
|
||||
expect(addClass).toHaveBeenCalledWith("sls-onboarding-invitation-action");
|
||||
const preventDefault = vi.fn();
|
||||
click!({ preventDefault });
|
||||
await vi.waitFor(() => expect(setupManager.startOnBoarding).toHaveBeenCalledOnce());
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => {
|
||||
const addHandler = vi.fn();
|
||||
const addCommand = vi.fn();
|
||||
@@ -54,6 +120,11 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
API: {
|
||||
addCommand,
|
||||
},
|
||||
UI: {
|
||||
confirm: {
|
||||
askInPopup: vi.fn(),
|
||||
},
|
||||
},
|
||||
appLifecycle: {
|
||||
onLoaded: {
|
||||
addHandler,
|
||||
@@ -65,6 +136,7 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
},
|
||||
} as any;
|
||||
const setupManager = {
|
||||
startOnBoarding: vi.fn(async () => await Promise.resolve(false)),
|
||||
onUseSetupURI: vi.fn(async () => await Promise.resolve(true)),
|
||||
onP2PManualSetup: vi.fn(async () => await Promise.resolve(true)),
|
||||
} as any;
|
||||
@@ -75,6 +147,12 @@ describe("setupObsidian/setupManagerHandlers", () => {
|
||||
const loadedHandler = addHandler.mock.calls[0][0] as () => Promise<boolean>;
|
||||
await loadedHandler();
|
||||
|
||||
expect(addCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "livesync-open-onboarding",
|
||||
name: "Open onboarding wizard",
|
||||
})
|
||||
);
|
||||
expect(addCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "livesync-opensetupuri",
|
||||
|
||||
Reference in New Issue
Block a user