mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-25 12:57:07 +00:00
Route pending settings through initialisation choices
This commit is contained in:
@@ -13,6 +13,9 @@ Accepted and implemented through Stage C1 and two bounded Stage C2
|
||||
landing-page improvements. The shared specification remains deliberately
|
||||
limited to one-key, immediately persisted controls. Complex pages retain their
|
||||
existing renderers instead of being forced through a general abstraction.
|
||||
Settings pending application which require database initialisation now delegate
|
||||
their decision, scheduling, and restart boundary to `SetupManager` and
|
||||
`Rebuilder`.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -502,6 +505,29 @@ conversion should be justified by useful settings-search coverage and retain
|
||||
the catalogue, persistence owner, and refresh boundaries established by Stage
|
||||
C1.
|
||||
|
||||
### Centralise initialisation for settings pending application
|
||||
|
||||
Settings which cannot take effect safely through immediate persistence remain
|
||||
in the settings tab's editing buffer. Their Apply action delegates to a focused
|
||||
`SetupManager` dialogue which asks whether the next start should use existing
|
||||
synchronisation data or the files in the current Vault. `SetupManager` reports
|
||||
user cancellation and validation or reservation failure distinctly instead of
|
||||
collapsing them into a boolean setup outcome. A settings-persistence exception
|
||||
still propagates after `Rebuilder` removes the reserved flag.
|
||||
|
||||
`Rebuilder.scheduleFetch()` and `Rebuilder.scheduleRebuild()` are the only
|
||||
owners of the corresponding flag files. They reserve the next-start operation
|
||||
before the callback persists the edited settings, remove the flag if that
|
||||
callback fails, and request the restart only after preparation succeeds. The
|
||||
settings tab must not write those flag files or request the restart directly.
|
||||
|
||||
A user cancellation returns to a separate confirmation which offers either to
|
||||
keep editing or to apply the settings without initialisation. This preserves
|
||||
the former advanced fallback without presenting it as an equal data-source
|
||||
choice. A validation or flag-reservation failure is not a cancellation and
|
||||
must not offer that bypass. The pending action is present on the native root
|
||||
settings page as well as within custom and native child pages.
|
||||
|
||||
## Verification
|
||||
|
||||
Stage A runs the maintained onboarding E2E scenario and an ordinary settings
|
||||
@@ -533,6 +559,12 @@ Stage C1 and the landing-page focused unit tests verify:
|
||||
- the three Extra menus controls are registered once and refresh page
|
||||
visibility after persistence;
|
||||
- every standard setting key is registered once;
|
||||
- a setting pending application exposes its Apply action on the native root
|
||||
page;
|
||||
- cancelling the initialisation choice preserves the editing buffer, while the
|
||||
separately confirmed settings-only path persists it;
|
||||
- Fetch and Rebuild reserve their flag through `Rebuilder` before pending
|
||||
settings are persisted, and a reservation failure leaves them unapplied;
|
||||
- reads use the editing buffer;
|
||||
- writes use `saveSettings([key])` and never `plugin.settings`;
|
||||
- custom pages dispose their page-owned resources and do not duplicate saved
|
||||
@@ -556,6 +588,13 @@ Real-Obsidian verification on 1.13 or later confirms:
|
||||
- no duplicate save, update handler, or saved-setting effect occurs after
|
||||
leaving and reopening a page.
|
||||
|
||||
The maintained real-Obsidian settings scenario also changes a setting which
|
||||
remains pending until initialisation, captures the source-choice and
|
||||
settings-only fallback dialogues, and confirms that keeping the setting
|
||||
pending does not persist it. It mounts the P2P variant directly to confirm that
|
||||
it offers a source device and local Vault preparation without presenting a
|
||||
central-server overwrite operation.
|
||||
|
||||
Because the manifest continues to support earlier Obsidian versions, a
|
||||
pre-1.13 real-runtime smoke test must confirm that the imperative fallback
|
||||
still opens, navigates, saves one Advanced value, and opens one custom page. If
|
||||
|
||||
@@ -28,6 +28,28 @@ The following status applies to optional and compatibility features in the 1.0 l
|
||||
| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. |
|
||||
| Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. |
|
||||
|
||||
### Apply changes which require initialisation
|
||||
|
||||
Some compatibility settings are not saved immediately. They remain pending
|
||||
until **Apply** is selected. The Apply action remains visible on the root
|
||||
settings page and on the relevant child page. The following dialogue asks which
|
||||
existing data should be used after restarting:
|
||||
|
||||
- **Reset Synchronisation on This Device** reconstructs this device's local
|
||||
database from the configured remote. For P2P, an online source device is
|
||||
selected after restart.
|
||||
- **Overwrite Server Data with This Device's Files** reconstructs the local and
|
||||
remote databases from this Vault. The P2P equivalent prepares only this
|
||||
device from its current Vault files.
|
||||
- **Review another way to apply these settings** returns to a separate choice
|
||||
between keeping the changes pending and applying them without initialisation.
|
||||
Applying them alone is an advanced compatibility fallback and can make the
|
||||
device incompatible with its existing synchronisation data.
|
||||
|
||||
LiveSync reserves the selected next-start operation before saving the pending
|
||||
settings. If validation or that reservation fails, the settings remain
|
||||
unapplied and the settings-only fallback is not offered.
|
||||
|
||||
| Icon | Description |
|
||||
| :--: | ------------------------------------------------------------------ |
|
||||
| 💬 | [0. Change Log](#0-change-log) |
|
||||
|
||||
@@ -11715,6 +11715,72 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "覆盖",
|
||||
"zh-tw": "覆寫",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": {
|
||||
def: "Apply without Initialisation",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Back": {
|
||||
def: "Review another way to apply these settings",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": {
|
||||
def: "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": {
|
||||
def: "Apply Settings without Initialisation?",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": {
|
||||
def: "Continue with Fetch",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": {
|
||||
def: "Reset Synchronisation on This Device",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": {
|
||||
def: "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": {
|
||||
def: "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": {
|
||||
def: "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": {
|
||||
def: "Keep Editing",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": {
|
||||
def: "Restart and Fetch Synchronisation Data",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": {
|
||||
def: "Restart and Select a Source Device",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": {
|
||||
def: "Restart and Overwrite Server Data",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": {
|
||||
def: "Restart and Prepare This Device",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Question": {
|
||||
def: "Which existing data should be used after restart?",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": {
|
||||
def: "Overwrite Server Data with This Device's Files",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": {
|
||||
def: "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": {
|
||||
def: "Prepare This Device from This Vault",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": {
|
||||
def: "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": {
|
||||
def: "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": {
|
||||
def: "Remote Synchronisation Data Could Not Be Verified",
|
||||
},
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Title": {
|
||||
def: "Apply Settings and Reinitialise Synchronisation",
|
||||
},
|
||||
"Ui.SetupWizard.Common.Back": {
|
||||
def: "No, please take me back",
|
||||
es: "No, volver atrás",
|
||||
|
||||
@@ -1321,6 +1321,28 @@
|
||||
"Ui.Settings.SyncSettings.Fetch": "Fetch",
|
||||
"Ui.Settings.SyncSettings.Merge": "Merge",
|
||||
"Ui.Settings.SyncSettings.Overwrite": "Overwrite",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation": "Apply without Initialisation",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Back": "Review another way to apply these settings",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance": "Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle": "Apply Settings without Initialisation?",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch": "Continue with Fetch",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOption": "Reset Synchronisation on This Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc": "After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc": "After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Guidance": "These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing": "Keep Editing",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch": "Restart and Fetch Synchronisation Data",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P": "Restart and Select a Source Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild": "Restart and Overwrite Server Data",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P": "Restart and Prepare This Device",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Question": "Which existing data should be used after restart?",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption": "Overwrite Server Data with This Device's Files",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc": "Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P": "Prepare This Device from This Vault",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc": "Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance": "The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle": "Remote Synchronisation Data Could Not Be Verified",
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.Title": "Apply Settings and Reinitialise Synchronisation",
|
||||
"Ui.SetupWizard.Common.Back": "No, please take me back",
|
||||
"Ui.SetupWizard.Common.Cancel": "Cancel",
|
||||
"Ui.SetupWizard.Common.ProceedSelectOption": "Please select an option to proceed",
|
||||
|
||||
@@ -2309,6 +2309,29 @@ Ui:
|
||||
Fetch: Fetch
|
||||
Overwrite: Overwrite
|
||||
SetupWizard:
|
||||
ApplySettingsInitialisation:
|
||||
ApplyWithoutInitialisation: Apply without Initialisation
|
||||
Back: Review another way to apply these settings
|
||||
BypassGuidance: Applying these settings alone can make this device incompatible with its existing synchronisation data. Use this only when you have confirmed that reconstruction is unnecessary.
|
||||
BypassTitle: Apply Settings without Initialisation?
|
||||
ContinueFetch: Continue with Fetch
|
||||
FetchOption: Reset Synchronisation on This Device
|
||||
FetchOptionDesc: After restarting, rebuild this device's local database from the current remote synchronisation data. Files in the Vault will then be reconciled with that data.
|
||||
FetchOptionP2PDesc: After restarting, select an online source device. This device's local LiveSync database will be rebuilt from that source.
|
||||
Guidance: These setting changes alter how synchronisation data is interpreted. Apply them together with an initialisation operation after restart.
|
||||
KeepEditing: Keep Editing
|
||||
ProceedFetch: Restart and Fetch Synchronisation Data
|
||||
ProceedFetchP2P: Restart and Select a Source Device
|
||||
ProceedRebuild: Restart and Overwrite Server Data
|
||||
ProceedRebuildP2P: Restart and Prepare This Device
|
||||
Question: Which existing data should be used after restart?
|
||||
RebuildOption: Overwrite Server Data with This Device's Files
|
||||
RebuildOptionDesc: Rebuild the local and remote databases from the files currently in this Vault. Other synchronising devices must reset their local synchronisation afterwards.
|
||||
RebuildOptionP2P: Prepare This Device from This Vault
|
||||
RebuildOptionP2PDesc: Rebuild this device's local LiveSync database from the files currently in this Vault. This does not overwrite another device.
|
||||
RemoteVerificationGuidance: The configured remote could not be verified with the current credentials and encryption settings. Continuing may make the Fetch fail after restart.
|
||||
RemoteVerificationTitle: Remote Synchronisation Data Could Not Be Verified
|
||||
Title: Apply Settings and Reinitialise Synchronisation
|
||||
Common:
|
||||
Back: No, please take me back
|
||||
Cancel: Cancel
|
||||
|
||||
+16
-2
@@ -121,6 +121,10 @@ function isGroup(item: SettingDefinitionItem): item is SettingDefinitionGroup {
|
||||
return "type" in item && item.type === "group";
|
||||
}
|
||||
|
||||
function isAction(item: SettingDefinitionItem): item is Extract<SettingDefinitionItem, { action: unknown }> {
|
||||
return "action" in item && typeof item.action === "function";
|
||||
}
|
||||
|
||||
function itemLabel(item: SettingDefinitionItem): string {
|
||||
if (isPage(item)) return item.name;
|
||||
if (isGroup(item)) return item.heading ?? "";
|
||||
@@ -179,7 +183,7 @@ beforeEach(() => {
|
||||
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => {
|
||||
const tab = createSettingsTab();
|
||||
const definitions = tab.getSettingDefinitions();
|
||||
const definitions = tab.getSettingDefinitions().filter(isGroup);
|
||||
|
||||
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
|
||||
"🧙♂️ Quick Setup",
|
||||
@@ -191,7 +195,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => {
|
||||
const tab = createSettingsTab();
|
||||
tab.editingSettings.liveSync = true;
|
||||
const definitions = tab.getSettingDefinitions();
|
||||
const definitions = tab.getSettingDefinitions().filter(isGroup);
|
||||
|
||||
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
|
||||
"🔄 Synchronisation",
|
||||
@@ -200,6 +204,16 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the pending initialisation action visible on the root settings page", () => {
|
||||
const tab = createSettingsTab();
|
||||
tab.editingSettings.handleFilenameCaseSensitive = !tab.initialSettings!.handleFilenameCaseSensitive;
|
||||
|
||||
const action = tab.getSettingDefinitions().find(isAction);
|
||||
|
||||
expect(action?.name).toBe("Apply");
|
||||
expect(typeof action?.visible === "function" ? action.visible() : action?.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Remote Configuration and Sync Settings as native pages inside the Synchronisation group", () => {
|
||||
const tab = createSettingsTab();
|
||||
const definitions = tab.getSettingDefinitions();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
type RemoteDBSettings,
|
||||
LOG_LEVEL_NOTICE,
|
||||
FlagFilesHumanReadable,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type ConfigLevel,
|
||||
@@ -79,6 +78,7 @@ import type {
|
||||
} from "obsidian";
|
||||
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
|
||||
import { SetupManager } from "@/modules/features/SetupManager.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
|
||||
// For creating a document
|
||||
// const toc = new Set<string>();
|
||||
@@ -907,10 +907,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
]);
|
||||
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation];
|
||||
|
||||
const pendingInitialisation = this.createRebuildRequiredAction();
|
||||
if (this.isAnySyncEnabled()) {
|
||||
return [synchronisation, generalSettings, quickSetup, ...laterGroups];
|
||||
return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups];
|
||||
}
|
||||
return [quickSetup, synchronisation, generalSettings, ...laterGroups];
|
||||
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, ...laterGroups];
|
||||
}
|
||||
|
||||
private beginRenderScope(refresh: () => void): Component {
|
||||
@@ -1031,50 +1032,53 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
Logger(`Passphrase is not valid, please fix it.`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
const OPTION_FETCH = $msg("obsidianLiveSyncSettingTab.optionFetchFromRemote");
|
||||
const OPTION_REBUILD_BOTH = $msg("obsidianLiveSyncSettingTab.optionRebuildBoth");
|
||||
const OPTION_ONLY_SETTING = $msg("obsidianLiveSyncSettingTab.optionSaveOnlySettings");
|
||||
const OPTION_CANCEL = $msg("obsidianLiveSyncSettingTab.optionCancel");
|
||||
const title = $msg("obsidianLiveSyncSettingTab.titleRebuildRequired");
|
||||
const note = $msg("obsidianLiveSyncSettingTab.msgRebuildRequired", {
|
||||
OPTION_REBUILD_BOTH,
|
||||
OPTION_FETCH,
|
||||
OPTION_ONLY_SETTING,
|
||||
const keepEditing = $msg("Ui.SetupWizard.ApplySettingsInitialisation.KeepEditing");
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const result = await setupManager.applySettingsWithInitialisationChoice({
|
||||
isP2P: isP2PMainRemote(this.editingSettings),
|
||||
validateChoice: async (mode) => {
|
||||
if (mode !== "fetch" || (await this.checkWorkingPassphrase())) {
|
||||
return true;
|
||||
}
|
||||
const continueFetch = $msg("Ui.SetupWizard.ApplySettingsInitialisation.ContinueFetch");
|
||||
return (
|
||||
(await this.core.confirm.confirmWithMessage(
|
||||
$msg("Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationTitle"),
|
||||
$msg("Ui.SetupWizard.ApplySettingsInitialisation.RemoteVerificationGuidance"),
|
||||
[continueFetch, keepEditing],
|
||||
keepEditing
|
||||
)) === continueFetch
|
||||
);
|
||||
},
|
||||
applySettings: async () => {
|
||||
if (!this.editingSettings.encrypt) {
|
||||
this.editingSettings.passphrase = "";
|
||||
}
|
||||
await this.saveAllDirtySettings();
|
||||
},
|
||||
});
|
||||
const buttons = [
|
||||
OPTION_FETCH,
|
||||
OPTION_REBUILD_BOTH, // OPTION_REBUILD_REMOTE,
|
||||
OPTION_ONLY_SETTING,
|
||||
OPTION_CANCEL,
|
||||
];
|
||||
const result = await this.core.confirm.confirmWithMessage(title, note, buttons, OPTION_CANCEL);
|
||||
if (result == OPTION_CANCEL) return;
|
||||
if (result == OPTION_FETCH) {
|
||||
if (!(await this.checkWorkingPassphrase())) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog($msg("obsidianLiveSyncSettingTab.msgAreYouSureProceed"), {
|
||||
defaultOption: "No",
|
||||
})) != "yes"
|
||||
)
|
||||
return;
|
||||
if (result.result === "scheduled") {
|
||||
this.closeSetting();
|
||||
return;
|
||||
}
|
||||
if (result.result === "failed") {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyWithoutInitialisation = $msg(
|
||||
"Ui.SetupWizard.ApplySettingsInitialisation.ApplyWithoutInitialisation"
|
||||
);
|
||||
const fallback = await this.core.confirm.confirmWithMessage(
|
||||
$msg("Ui.SetupWizard.ApplySettingsInitialisation.BypassTitle"),
|
||||
$msg("Ui.SetupWizard.ApplySettingsInitialisation.BypassGuidance"),
|
||||
[applyWithoutInitialisation, keepEditing],
|
||||
keepEditing
|
||||
);
|
||||
if (fallback === applyWithoutInitialisation) {
|
||||
if (!this.editingSettings.encrypt) {
|
||||
this.editingSettings.passphrase = "";
|
||||
}
|
||||
}
|
||||
if (!this.editingSettings.encrypt) {
|
||||
this.editingSettings.passphrase = "";
|
||||
}
|
||||
await this.saveAllDirtySettings();
|
||||
await Promise.resolve(this.applyAllSettings());
|
||||
if (result == OPTION_FETCH) {
|
||||
await this.core.storageAccess.writeFileAuto(FlagFilesHumanReadable.FETCH_ALL, "");
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
this.closeSetting();
|
||||
// await rebuildDB("localOnly");
|
||||
} else if (result == OPTION_REBUILD_BOTH) {
|
||||
await this.core.storageAccess.writeFileAuto(FlagFilesHumanReadable.REBUILD_ALL, "");
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
this.closeSetting();
|
||||
} else if (result == OPTION_ONLY_SETTING) {
|
||||
await this.services.setting.saveSettingData();
|
||||
await this.saveAllDirtySettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
const negotiationMocks = vi.hoisted(() => ({
|
||||
checkSyncInfo: vi.fn(async () => true),
|
||||
}));
|
||||
const settingsInitialisationMocks = vi.hoisted(() => ({
|
||||
applySettingsWithInitialisationChoice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class {},
|
||||
@@ -63,6 +66,10 @@ vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
|
||||
beforeEach(() => {
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockReset();
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
it("closes the finite remote connection after checking synchronisation information", async () => {
|
||||
const remoteDatabase = {
|
||||
@@ -95,6 +102,116 @@ describe("ObsidianLiveSyncSettingTab passphrase verification", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
|
||||
function createSettingsTab() {
|
||||
const saveSettingData = vi.fn(async () => undefined);
|
||||
const confirmWithMessage = vi.fn();
|
||||
const plugin = {
|
||||
app: {},
|
||||
core: {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
handleFilenameCaseSensitive: false,
|
||||
},
|
||||
getModule: vi.fn(() => settingsInitialisationMocks),
|
||||
confirm: {
|
||||
confirmWithMessage,
|
||||
},
|
||||
services: {
|
||||
setting: {
|
||||
saveSettingData,
|
||||
getDeviceAndVaultName: vi.fn(() => ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
|
||||
Object.assign(tab, {
|
||||
_editingSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
handleFilenameCaseSensitive: true,
|
||||
},
|
||||
initialSettings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
handleFilenameCaseSensitive: false,
|
||||
},
|
||||
});
|
||||
vi.spyOn(tab, "isPassphraseValid").mockResolvedValue(true);
|
||||
vi.spyOn(tab, "checkWorkingPassphrase").mockResolvedValue(true);
|
||||
const closeSetting = vi.spyOn(tab, "closeSetting").mockImplementation(() => undefined);
|
||||
return { tab, saveSettingData, confirmWithMessage, closeSetting };
|
||||
}
|
||||
|
||||
it("keeps pending settings in the editing buffer when initialisation and the fallback are cancelled", async () => {
|
||||
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
|
||||
result: "cancelled",
|
||||
});
|
||||
confirmWithMessage.mockResolvedValueOnce("Keep Editing");
|
||||
|
||||
await tab.confirmRebuild();
|
||||
|
||||
expect(settingsInitialisationMocks.applySettingsWithInitialisationChoice).toHaveBeenCalledOnce();
|
||||
expect(confirmWithMessage).toHaveBeenCalledWith(
|
||||
"Apply Settings without Initialisation?",
|
||||
expect.any(String),
|
||||
["Apply without Initialisation", "Keep Editing"],
|
||||
"Keep Editing"
|
||||
);
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
expect(tab.editingSettings.handleFilenameCaseSensitive).toBe(true);
|
||||
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false);
|
||||
expect(closeSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies pending settings only after a separately confirmed initialisation bypass", async () => {
|
||||
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
|
||||
result: "cancelled",
|
||||
});
|
||||
confirmWithMessage.mockResolvedValueOnce("Apply without Initialisation");
|
||||
|
||||
await tab.confirmRebuild();
|
||||
|
||||
expect(settingsInitialisationMocks.applySettingsWithInitialisationChoice).toHaveBeenCalledOnce();
|
||||
expect(saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(true);
|
||||
expect(closeSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes settings only after initialisation has been scheduled", async () => {
|
||||
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockImplementationOnce(
|
||||
async ({ applySettings }: { applySettings: () => Promise<void> }) => {
|
||||
await applySettings();
|
||||
return { result: "scheduled", mode: "rebuild" };
|
||||
}
|
||||
);
|
||||
|
||||
await tab.confirmRebuild();
|
||||
|
||||
expect(saveSettingData).toHaveBeenCalledOnce();
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(closeSetting).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not offer the settings-only fallback after an initialisation failure", async () => {
|
||||
const { tab, saveSettingData, confirmWithMessage, closeSetting } = createSettingsTab();
|
||||
settingsInitialisationMocks.applySettingsWithInitialisationChoice.mockResolvedValueOnce({
|
||||
result: "failed",
|
||||
mode: "fetch",
|
||||
});
|
||||
|
||||
await tab.confirmRebuild();
|
||||
|
||||
expect(saveSettingData).not.toHaveBeenCalled();
|
||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||
expect(tab.editingSettings.handleFilenameCaseSensitive).toBe(true);
|
||||
expect(tab.core.settings.handleFilenameCaseSensitive).toBe(false);
|
||||
expect(closeSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
|
||||
function createSettingsTab() {
|
||||
const saveSettingData = vi.fn(async () => undefined);
|
||||
|
||||
@@ -19,6 +19,7 @@ import UseSetupURI from "./SetupWizard/dialogs/UseSetupURI.svelte";
|
||||
import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
|
||||
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
|
||||
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
|
||||
import ApplySettingsInitialisation from "./SetupWizard/dialogs/ApplySettingsInitialisation.svelte";
|
||||
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
@@ -38,10 +39,13 @@ import type {
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
UseSetupURIResultType,
|
||||
ApplySettingsInitialisationResultType,
|
||||
ApplySettingsInitialisationInitialData,
|
||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
import {
|
||||
applySettingsAndFetchOnActivation,
|
||||
applySettingsWithScheduledInitialisation,
|
||||
type SetupInitialisationMode,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
|
||||
@@ -75,6 +79,17 @@ export const enum UserMode {
|
||||
Update = "unknown", // Alias for Unknown for better readability
|
||||
}
|
||||
|
||||
export type SettingsInitialisationApplicationResult =
|
||||
| { result: "scheduled"; mode: SetupInitialisationMode }
|
||||
| { result: "cancelled" }
|
||||
| { result: "failed"; mode: SetupInitialisationMode };
|
||||
|
||||
export type ApplySettingsWithInitialisationChoiceOptions = {
|
||||
applySettings: () => Promise<void>;
|
||||
isP2P: boolean;
|
||||
validateChoice?: (mode: SetupInitialisationMode) => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup Manager to handle onboarding and configuration setup
|
||||
*/
|
||||
@@ -87,6 +102,32 @@ export class SetupManager extends AbstractModule {
|
||||
return this.services.UI.dialogManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask which existing data should be authoritative for pending setting changes,
|
||||
* then reserve the matching next-start operation before applying them.
|
||||
*
|
||||
* Cancellation and reservation failure remain distinct so the caller may
|
||||
* offer an explicit settings-only fallback only after a user cancellation.
|
||||
*/
|
||||
async applySettingsWithInitialisationChoice({
|
||||
applySettings,
|
||||
isP2P,
|
||||
validateChoice = () => Promise.resolve(true),
|
||||
}: ApplySettingsWithInitialisationChoiceOptions): Promise<SettingsInitialisationApplicationResult> {
|
||||
const mode = await this.dialogManager.openWithExplicitCancel<
|
||||
ApplySettingsInitialisationResultType,
|
||||
ApplySettingsInitialisationInitialData
|
||||
>(ApplySettingsInitialisation, { isP2P });
|
||||
if (mode === "cancelled") {
|
||||
return { result: "cancelled" };
|
||||
}
|
||||
if (!(await validateChoice(mode))) {
|
||||
return { result: "failed", mode };
|
||||
}
|
||||
const scheduled = await applySettingsWithScheduledInitialisation(this.core.rebuilder, mode, applySettings);
|
||||
return scheduled ? { result: "scheduled", mode } : { result: "failed", mode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the onboarding process
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("./SetupWizard/dialogs/UseSetupURI.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/OutroNewUser.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/OutroExistingUser.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/ApplySettingsInitialisation.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
|
||||
@@ -273,6 +274,64 @@ describe("SetupManager", () => {
|
||||
expect(setting.currentSettings().isConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports cancellation separately when applying settings which require initialisation", async () => {
|
||||
const { manager, dialogManager, core } = createSetupManager();
|
||||
const applySettings = vi.fn(async () => undefined);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
|
||||
const result = await manager.applySettingsWithInitialisationChoice({ applySettings, isP2P: true });
|
||||
|
||||
expect(result).toEqual({ result: "cancelled" });
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), { isP2P: true });
|
||||
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(core.rebuilder.scheduleRebuild).not.toHaveBeenCalled();
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reserves the selected initialisation before applying pending settings", async () => {
|
||||
const { manager, dialogManager, core } = createSetupManager();
|
||||
const applySettings = vi.fn(async () => undefined);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("fetch");
|
||||
|
||||
const result = await manager.applySettingsWithInitialisationChoice({ applySettings, isP2P: false });
|
||||
|
||||
expect(result).toEqual({ result: "scheduled", mode: "fetch" });
|
||||
expect(core.rebuilder.scheduleFetch).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(core.rebuilder.scheduleFetch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
applySettings.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a failed reservation without applying pending settings", async () => {
|
||||
const { manager, dialogManager, core } = createSetupManager();
|
||||
const applySettings = vi.fn(async () => undefined);
|
||||
core.rebuilder.scheduleRebuild.mockResolvedValueOnce(false);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("rebuild");
|
||||
|
||||
const result = await manager.applySettingsWithInitialisationChoice({ applySettings, isP2P: false });
|
||||
|
||||
expect(result).toEqual({ result: "failed", mode: "rebuild" });
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reserve initialisation when the selected source cannot be validated", async () => {
|
||||
const { manager, dialogManager, core } = createSetupManager();
|
||||
const applySettings = vi.fn(async () => undefined);
|
||||
const validateChoice = vi.fn(async () => false);
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("fetch");
|
||||
|
||||
const result = await manager.applySettingsWithInitialisationChoice({
|
||||
applySettings,
|
||||
isP2P: false,
|
||||
validateChoice,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ result: "failed", mode: "fetch" });
|
||||
expect(validateChoice).toHaveBeenCalledWith("fetch");
|
||||
expect(core.rebuilder.scheduleFetch).not.toHaveBeenCalled();
|
||||
expect(applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves modern profiles, display names, and the active selection from a Setup URI", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const imported = {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import Question from "@/modules/services/LiveSyncUI/components/Question.svelte";
|
||||
import Option from "@/modules/services/LiveSyncUI/components/Option.svelte";
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import { $msg as msg } from "@/common/translation";
|
||||
import {
|
||||
type ApplySettingsInitialisationInitialData,
|
||||
type ApplySettingsInitialisationResultType,
|
||||
TYPE_CANCELLED,
|
||||
TYPE_FETCH,
|
||||
TYPE_REBUILD,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ApplySettingsInitialisationResultType) => void;
|
||||
getInitialData?: () => ApplySettingsInitialisationInitialData | undefined;
|
||||
};
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const isP2P = $derived(getInitialData?.()?.isP2P === true);
|
||||
let selectedMode = $state<ApplySettingsInitialisationResultType>(TYPE_CANCELLED);
|
||||
const canProceed = $derived(selectedMode === TYPE_FETCH || selectedMode === TYPE_REBUILD);
|
||||
const proceedMessage = $derived.by(() => {
|
||||
if (selectedMode === TYPE_FETCH) {
|
||||
return isP2P
|
||||
? msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetchP2P")
|
||||
: msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedFetch");
|
||||
}
|
||||
if (selectedMode === TYPE_REBUILD) {
|
||||
return isP2P
|
||||
? msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuildP2P")
|
||||
: msg("Ui.SetupWizard.ApplySettingsInitialisation.ProceedRebuild");
|
||||
}
|
||||
return msg("Ui.SetupWizard.Common.ProceedSelectOption");
|
||||
});
|
||||
</script>
|
||||
|
||||
<DialogHeader title={msg("Ui.SetupWizard.ApplySettingsInitialisation.Title")} />
|
||||
<Guidance>
|
||||
<p>{msg("Ui.SetupWizard.ApplySettingsInitialisation.Guidance")}</p>
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>{msg("Ui.SetupWizard.ApplySettingsInitialisation.Question")}</Question>
|
||||
<Option
|
||||
title={msg("Ui.SetupWizard.ApplySettingsInitialisation.FetchOption")}
|
||||
bind:value={selectedMode}
|
||||
selectedValue={TYPE_FETCH}
|
||||
>
|
||||
<InfoNote notice>
|
||||
{isP2P
|
||||
? msg("Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionP2PDesc")
|
||||
: msg("Ui.SetupWizard.ApplySettingsInitialisation.FetchOptionDesc")}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
<Option
|
||||
title={isP2P
|
||||
? msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2P")
|
||||
: msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOption")}
|
||||
bind:value={selectedMode}
|
||||
selectedValue={TYPE_REBUILD}
|
||||
>
|
||||
<InfoNote warning={!isP2P} notice={isP2P}>
|
||||
{isP2P
|
||||
? msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionP2PDesc")
|
||||
: msg("Ui.SetupWizard.ApplySettingsInitialisation.RebuildOptionDesc")}
|
||||
</InfoNote>
|
||||
</Option>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
<Decision
|
||||
title={proceedMessage}
|
||||
important={selectedMode !== TYPE_REBUILD || isP2P}
|
||||
destructive={selectedMode === TYPE_REBUILD && !isP2P}
|
||||
disabled={!canProceed}
|
||||
commit={() => setResult(selectedMode)}
|
||||
/>
|
||||
<Decision
|
||||
title={msg("Ui.SetupWizard.ApplySettingsInitialisation.Back")}
|
||||
commit={() => setResult(TYPE_CANCELLED)}
|
||||
/>
|
||||
</UserDecisions>
|
||||
@@ -28,6 +28,10 @@ export const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
|
||||
// OutroExistingUser
|
||||
export const TYPE_APPLY = "apply";
|
||||
|
||||
// Applying pending settings which require database initialisation
|
||||
export const TYPE_FETCH = "fetch";
|
||||
export const TYPE_REBUILD = "rebuild";
|
||||
|
||||
// Select methods
|
||||
export const TYPE_USE_SETUP_URI = "use-setup-uri";
|
||||
export const TYPE_SCAN_QR_CODE = "scan-qr-code";
|
||||
@@ -82,6 +86,12 @@ export type OutroExistingUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCEL
|
||||
|
||||
export type OutroNewUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
|
||||
export type ApplySettingsInitialisationResultType = typeof TYPE_FETCH | typeof TYPE_REBUILD | typeof TYPE_CANCELLED;
|
||||
|
||||
export type ApplySettingsInitialisationInitialData = {
|
||||
isP2P: boolean;
|
||||
};
|
||||
|
||||
export type SelectMethodNewUserResultType =
|
||||
| typeof TYPE_USE_SETUP_URI
|
||||
| typeof TYPE_CONFIGURE_MANUALLY
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Locator } from "playwright";
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000);
|
||||
const settingsOnly = process.env.E2E_OBSIDIAN_SETTINGS_ONLY === "true";
|
||||
const diagnosticsDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
const settingsInitialisationRunStateKey = "__livesyncE2ESettingsInitialisation";
|
||||
const settingsScreenshotOptions = {
|
||||
animations: "disabled" as const,
|
||||
style: ".notice-container { visibility: hidden !important; }",
|
||||
@@ -27,6 +28,16 @@ const compatibilityReviewMessage = "Review the internal database compatibility c
|
||||
|
||||
type LiveSyncTestPlugin = {
|
||||
core: {
|
||||
modules: {
|
||||
constructor: { name: string };
|
||||
applySettingsWithInitialisationChoice?: (options: {
|
||||
applySettings: () => Promise<void>;
|
||||
isP2P: boolean;
|
||||
}) => Promise<unknown>;
|
||||
}[];
|
||||
settings: {
|
||||
handleFilenameCaseSensitive: boolean;
|
||||
};
|
||||
services: {
|
||||
setting: {
|
||||
currentSettings(): {
|
||||
@@ -44,6 +55,12 @@ type LiveSyncTestPlugin = {
|
||||
};
|
||||
};
|
||||
|
||||
type SettingsInitialisationRunState = {
|
||||
done: boolean;
|
||||
error?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
plugins?: { plugins: Record<string, LiveSyncTestPlugin | undefined> };
|
||||
};
|
||||
@@ -155,6 +172,96 @@ async function captureDeclarativeMobileLanding(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettingsInitialisationDialogueForInspection(isP2P: boolean): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.evaluate(
|
||||
({ stateKey, isP2P }) => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
||||
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
|
||||
if (typeof manager?.applySettingsWithInitialisationChoice !== "function") {
|
||||
throw new Error("Could not find the pending-settings initialisation workflow");
|
||||
}
|
||||
const state: SettingsInitialisationRunState = { done: false };
|
||||
(globalThis as unknown as Record<string, SettingsInitialisationRunState>)[stateKey] = state;
|
||||
void manager
|
||||
.applySettingsWithInitialisationChoice({
|
||||
isP2P,
|
||||
applySettings: Promise.resolve.bind(Promise),
|
||||
})
|
||||
.then(
|
||||
(result) => {
|
||||
state.result = result;
|
||||
state.done = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
state.error = error instanceof Error ? error.message : String(error);
|
||||
state.done = true;
|
||||
}
|
||||
);
|
||||
},
|
||||
{ stateKey: settingsInitialisationRunStateKey, isP2P }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function assertSettingsInitialisationRunCancelled(): Promise<void> {
|
||||
const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
await page.waitForFunction(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, SettingsInitialisationRunState | undefined>)[stateKey]
|
||||
?.done === true,
|
||||
settingsInitialisationRunStateKey,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
return await page.evaluate(
|
||||
(stateKey) =>
|
||||
(globalThis as unknown as Record<string, SettingsInitialisationRunState | undefined>)[stateKey],
|
||||
settingsInitialisationRunStateKey
|
||||
);
|
||||
});
|
||||
if (!state) throw new Error("The pending-settings initialisation dialogue did not record its result.");
|
||||
if (state.error) throw new Error(`The pending-settings initialisation dialogue failed: ${state.error}`);
|
||||
if (JSON.stringify(state.result) !== JSON.stringify({ result: "cancelled" })) {
|
||||
throw new Error(`The pending-settings initialisation dialogue returned ${JSON.stringify(state.result)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureP2PSettingsInitialisationDialogue(): Promise<string> {
|
||||
await openSettingsInitialisationDialogueForInspection(true);
|
||||
const path = `${diagnosticsDirectory}/settings-initialisation-p2p.png`;
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(
|
||||
page,
|
||||
"Apply Settings and Reinitialise Synchronisation",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await dialogue.getByText("Prepare This Device from This Vault", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await dialogue.getByText("Reset Synchronisation on This Device", { exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await dialogue.getByRole("button", { name: "Restart and Select a Source Device", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
if (
|
||||
(await dialogue.getByText("Overwrite Server Data with This Device's Files", { exact: true }).count()) !== 0
|
||||
) {
|
||||
throw new Error("The P2P initialisation dialogue exposed the central-server overwrite operation.");
|
||||
}
|
||||
await dialogue.screenshot({ ...settingsScreenshotOptions, path });
|
||||
await dialogue
|
||||
.getByRole("button", { name: "Review another way to apply these settings", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await dialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
await assertSettingsInitialisationRunCancelled();
|
||||
return path;
|
||||
}
|
||||
|
||||
async function resumePendingCompatibilityReviewForSettings(): Promise<void> {
|
||||
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const review = page.locator(".modal-container").filter({
|
||||
@@ -544,6 +651,105 @@ async function verifyEffectiveSettings(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyPendingSettingsInitialisationFlow(): Promise<{ choice: string; fallback: string }> {
|
||||
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
|
||||
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
|
||||
if (settingsNavigator.renderer === "imperative") {
|
||||
const general = await settingsNavigator.openPage("General Settings");
|
||||
const edgeCaseMode = general.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Enable edge case treatment features", { exact: true }),
|
||||
});
|
||||
await edgeCaseMode.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
return plugin?.core.services.setting.currentSettings().useEdgeCaseMode === true;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
}
|
||||
|
||||
const patches = await settingsNavigator.openPage("Patches");
|
||||
const caseSensitiveSetting = patches.locator(".setting-item").filter({
|
||||
has: settingsNavigator.page.getByText("Handle files as Case-Sensitive", { exact: true }),
|
||||
});
|
||||
await caseSensitiveSetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const toggle = caseSensitiveSetting.locator(".checkbox-container");
|
||||
if ((await toggle.evaluate((element) => element.classList.contains("is-enabled"))) === true) {
|
||||
throw new Error("The pending-settings fixture expected case-sensitive file handling to be disabled.");
|
||||
}
|
||||
await toggle.click({ timeout: uiTimeoutMs });
|
||||
|
||||
if (settingsNavigator.renderer === "declarative") {
|
||||
await settingsNavigator.returnToCatalogue();
|
||||
}
|
||||
const applySetting = settingsNavigator.dialogue
|
||||
.locator(settingsNavigator.renderer === "declarative" ? ".setting-item" : ".sls-setting-menu-buttons")
|
||||
.filter({
|
||||
has: settingsNavigator.page.getByText("Changes need to be applied!", { exact: true }),
|
||||
});
|
||||
await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await applySetting.getByRole("button", { name: "Apply", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
|
||||
const choiceDialogue = await waitForVisibleObsidianDialogue(
|
||||
settingsNavigator.page,
|
||||
"Apply Settings and Reinitialise Synchronisation",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await choiceDialogue.getByText("Reset Synchronisation on This Device", { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await choiceDialogue
|
||||
.getByText("Overwrite Server Data with This Device's Files", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await choiceDialogue.getByText("Reset Synchronisation on This Device", { exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await choiceDialogue
|
||||
.getByRole("button", { name: "Restart and Fetch Synchronisation Data", exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const choice = `${diagnosticsDirectory}/settings-initialisation-choice.png`;
|
||||
await choiceDialogue.screenshot({ ...settingsScreenshotOptions, path: choice });
|
||||
await choiceDialogue
|
||||
.getByRole("button", { name: "Review another way to apply these settings", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await choiceDialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
|
||||
const fallbackDialogue = await waitForVisibleObsidianDialogue(
|
||||
settingsNavigator.page,
|
||||
"Apply Settings without Initialisation?",
|
||||
uiTimeoutMs
|
||||
);
|
||||
await fallbackDialogue
|
||||
.getByRole("button", { name: "Apply without Initialisation", exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await fallbackDialogue.getByRole("button", { name: "Keep Editing", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const fallback = `${diagnosticsDirectory}/settings-initialisation-fallback.png`;
|
||||
await fallbackDialogue.screenshot({ ...settingsScreenshotOptions, path: fallback });
|
||||
await fallbackDialogue.getByRole("button", { name: "Keep Editing", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await fallbackDialogue.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
|
||||
const persistedCaseSensitivity = await page.evaluate(() => {
|
||||
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
|
||||
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
|
||||
return plugin.core.settings.handleFilenameCaseSensitive;
|
||||
});
|
||||
if (persistedCaseSensitivity !== false) {
|
||||
throw new Error("Cancelling initialisation unexpectedly persisted the pending case-sensitivity setting.");
|
||||
}
|
||||
await applySetting.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settingsNavigator.close();
|
||||
return { choice, fallback };
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -593,8 +799,13 @@ async function main(): Promise<void> {
|
||||
await verifyConfigDoctorFollowsCompatibilityReview();
|
||||
}
|
||||
await verifyEffectiveSettings();
|
||||
const initialisation = await verifyPendingSettingsInitialisationFlow();
|
||||
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
|
||||
const mobileLanding = await captureDeclarativeMobileLanding();
|
||||
if (mobileLanding) console.log(`Declarative mobile settings landing page: ${mobileLanding}`);
|
||||
console.log(
|
||||
`Pending-settings initialisation screenshots: ${initialisation.choice}, ${initialisation.fallback}, ${p2pInitialisation}`
|
||||
);
|
||||
console.log("Compatibility review and settings expose only effective user controls.");
|
||||
} finally {
|
||||
if (session) {
|
||||
|
||||
@@ -21,6 +21,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
#### Improved
|
||||
|
||||
- Settings pages, General controls, Quick Setup actions, and Advanced controls now use Obsidian 1.13's native settings interface and global search, while retaining their familiar icons. The landing page keeps Remote Configuration and Sync Settings together, places Appearance, Logging, and Extra menus under General Settings, and groups maintenance, optional features, advanced settings, and help by purpose. Earlier supported Obsidian versions continue to use the pane-based interface.
|
||||
- Settings changes which require database initialisation now use a focused Setup Manager dialogue to choose between existing synchronisation data and the files in the current Vault. The selected reset or rebuild is reserved before the settings are saved, while cancelling offers a separate, explicit settings-only fallback.
|
||||
|
||||
## 1.0.18
|
||||
|
||||
|
||||
Reference in New Issue
Block a user