Reorganise settings around common tasks

This commit is contained in:
vorotamoroz
2026-08-25 05:34:57 +00:00
parent 8adc88b8cb
commit 070ce0e307
26 changed files with 1206 additions and 417 deletions
@@ -0,0 +1,75 @@
import { $msg, $t } from "@/common/translation";
import { SUPPORTED_I18N_LANGS } from "@/common/rosetta";
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type GeneralSettingSpecContext = {
showEditorStatusDetails: () => boolean;
showVerboseLog: () => boolean;
};
/** Build the shared Appearance and Logging controls. */
export function createGeneralSettingSpecGroups({
showEditorStatusDetails,
showVerboseLog,
}: GeneralSettingSpecContext): readonly SettingSpecGroup[] {
return [
{
heading: $msg("obsidianLiveSyncSettingTab.titleAppearance"),
items: [
{
key: "displayLanguage",
control: {
type: "dropdown",
options: () =>
Object.fromEntries(
SUPPORTED_I18N_LANGS.map((language) => [language, $t(`lang-${language}`)])
),
},
},
{ key: "showStatusOnEditor", control: { type: "toggle" } },
{
key: "showOnlyIconsOnEditor",
control: { type: "toggle" },
visible: showEditorStatusDetails,
},
{ key: "showStatusOnStatusbar", control: { type: "toggle" } },
{ key: "hideFileWarningNotice", control: { type: "toggle" } },
{
key: "networkWarningStyle",
control: {
type: "dropdown",
options: () => ({
[NetworkWarningStyles.BANNER]: "Show full banner",
[NetworkWarningStyles.ICON]: "Show icon only",
[NetworkWarningStyles.HIDDEN]: "Hide completely",
}),
},
},
],
},
{
heading: $msg("obsidianLiveSyncSettingTab.titleLogging"),
items: [
{ key: "lessInformationInLog", control: { type: "toggle" } },
{
key: "showVerboseLog",
control: { type: "toggle" },
visible: showVerboseLog,
},
],
},
];
}
/** Build the feature-level controls shown in General Settings under Extra menus. */
export function createExtraMenuSettingSpecGroup(): SettingSpecGroup {
return {
heading: $msg("obsidianLiveSyncSettingTab.titleExtraMenus"),
items: [
{ key: "useAdvancedMode", control: { type: "toggle" } },
{ key: "usePowerUserMode", control: { type: "toggle" } },
{ key: "useEdgeCaseMode", control: { type: "toggle" } },
],
};
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
import type { SettingDefinitionGroup, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
import type { PageFunctions } from "./SettingPane.ts";
const runtime = vi.hoisted(() => ({
@@ -9,7 +9,7 @@ const runtime = vi.hoisted(() => ({
unload: ReturnType<typeof vi.fn>;
callbacks: Array<() => unknown>;
}>,
paneGeneral: vi.fn(),
paneChangeLog: vi.fn(),
pageCleanup: vi.fn(),
savedEffect: vi.fn(),
superHide: vi.fn(),
@@ -73,9 +73,14 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_ON_UNRESOLVED_ERROR: "on-unresolved-error",
EVENT_REQUEST_COPY_SETUP_URI: "request-copy-setup-uri",
EVENT_REQUEST_OPEN_SETUP_URI: "request-open-setup-uri",
EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab",
eventHub: { onEvent: vi.fn() },
EVENT_REQUEST_SHOW_SETUP_QR: "request-show-setup-qr",
eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() },
}));
vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} }));
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({ checkSyncInfo: vi.fn() }));
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
LiveSyncCouchDBReplicator: class {},
@@ -91,9 +96,10 @@ vi.mock("./SettingPane.ts", () => ({
setStyle: vi.fn(),
visibleOnly: vi.fn((condition: () => boolean) => () => ({ visibility: condition() })),
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() }));
vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: runtime.paneGeneral }));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: runtime.paneChangeLog }));
vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() }));
vi.mock("./PaneHelp.ts", () => ({ paneHelp: vi.fn() }));
vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() }));
vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() }));
vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() }));
vi.mock("./PaneSyncSettings.ts", () => ({ paneSyncSettings: vi.fn() }));
@@ -111,6 +117,30 @@ function isPage(item: SettingDefinitionItem): item is SettingDefinitionPage {
return "type" in item && item.type === "page";
}
function isGroup(item: SettingDefinitionItem): item is SettingDefinitionGroup {
return "type" in item && item.type === "group";
}
function itemLabel(item: SettingDefinitionItem): string {
if (isPage(item)) return item.name;
if (isGroup(item)) return item.heading ?? "";
return item.name;
}
function collectPages(items: readonly SettingDefinitionItem[]): SettingDefinitionPage[] {
return items.flatMap((item) => {
if (isPage(item)) return [item, ...collectPages(item.items ?? [])];
if (isGroup(item)) return collectPages(item.items ?? []);
return [];
});
}
function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinitionPage {
const page = collectPages(tab.getSettingDefinitions()).find((candidate) => candidate.name.endsWith(` ${name}`));
if (!page) throw new Error(`${name} custom page is unavailable`);
return page;
}
function createSettingsTab(): ObsidianLiveSyncSettingTab {
const plugin = {
app: {},
@@ -137,8 +167,8 @@ function createSettingsTab(): ObsidianLiveSyncSettingTab {
beforeEach(() => {
runtime.components.length = 0;
runtime.paneGeneral.mockClear();
runtime.paneGeneral.mockImplementation(function (this: ObsidianLiveSyncSettingTab) {
runtime.paneChangeLog.mockClear();
runtime.paneChangeLog.mockImplementation(function (this: ObsidianLiveSyncSettingTab) {
this.lifetimeComponent.register(runtime.pageCleanup);
});
runtime.pageCleanup.mockClear();
@@ -147,36 +177,175 @@ beforeEach(() => {
});
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("returns all catalogue pages and keeps Advanced as native items", () => {
it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => {
const tab = createSettingsTab();
const pages = tab.getSettingDefinitions().filter(isPage);
const definitions = tab.getSettingDefinitions();
expect(pages).toHaveLength(12);
expect(pages.map(({ name }) => name)).toEqual(
createSettingsPageCatalogue().map((entry) => `${entry.icon} ${entry.name()}`)
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
"🧙‍♂️ Quick Setup",
"🔄 Synchronisation",
"⚙️ General Settings",
]);
});
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();
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
"🔄 Synchronisation",
"⚙️ General Settings",
"🧙‍♂️ Quick Setup",
]);
});
it("keeps Remote Configuration and Sync Settings as native pages inside the Synchronisation group", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const synchronisation = definitions.find(
(item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "🔄 Synchronisation"
);
expect(synchronisation?.items?.filter(isPage).map(({ name }) => name)).toEqual([
"🛰️ Remote Configuration",
"🔄 Sync Settings",
]);
expect(
definitions
.filter(isPage)
.map(({ name }) => name)
.filter((name) => name.endsWith(" Remote Configuration") || name.endsWith(" Sync Settings"))
).toEqual([]);
});
it("groups secondary pages by purpose instead of exposing a flat Detailed settings list", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const groups = definitions.filter(isGroup);
expect(groups.map(({ heading }) => heading)).toEqual([
"🧙‍♂️ Quick Setup",
"🔄 Synchronisation",
"⚙️ General Settings",
"📲 Set up other devices",
"🛠️ Maintenance and recovery",
"🧩 Extra features",
"🔧 Advanced settings",
"️ Help and information",
]);
expect(
groups
.find(({ heading }) => heading === "🛠️ Maintenance and recovery")
?.items?.filter(isPage)
.map(({ name }) => name)
).toEqual(["🎛️ Maintenance", "🧰 Hatch"]);
expect(
groups
.find(({ heading }) => heading === "🧩 Extra features")
?.items?.filter(isPage)
.map(({ name }) => name)
).toEqual(["🚦 Selector", "🔌 Customisation sync"]);
expect(
groups
.find(({ heading }) => heading === "🔧 Advanced settings")
?.items?.filter(isPage)
.map(({ name }) => name)
).toEqual(["🔧 Advanced", "💪 Power users", "🩹 Patches"]);
expect(
groups
.find(({ heading }) => heading === "️ Help and information")
?.items?.filter(isPage)
.map(({ name }) => name)
).toEqual(["❓ Help and troubleshooting", "💬 Change Log"]);
expect(
groups.find(({ heading }) => heading === "📲 Set up other devices")?.items?.map(({ name }) => name)
).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]);
});
it("keeps Appearance, Logging, and Extra menus inside General Settings", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const general = definitions.find(
(item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "⚙️ General Settings"
);
const generalPages = general?.items?.filter(isPage);
const appearance = generalPages?.find(({ name }) => name === "🎨 Appearance");
const logging = generalPages?.find(({ name }) => name === "📝 Logging");
const extraMenus = general?.items?.find(
(item): item is SettingDefinitionPage => isPage(item) && item.name === "🎚️ Extra menus"
);
expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "🎚️ Extra menus"]);
expect(
appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"networkWarningStyle",
]);
expect(
logging?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual(["lessInformationInLog", "showVerboseLog"]);
expect(
extraMenus?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]);
});
it("omits the old Setup child page and keeps standard General and Advanced pages native", () => {
const tab = createSettingsTab();
const pages = collectPages(tab.getSettingDefinitions());
expect(pages).toHaveLength(14);
expect(pages.map(({ name }) => name)).toEqual(
expect.arrayContaining(
createSettingsPageCatalogue()
.filter(({ id }) => id !== "general" && id !== "quick-setup")
.map((entry) => `${entry.icon} ${entry.name()}`)
)
);
expect(pages.some(({ name }) => name.endsWith(" General Settings"))).toBe(false);
expect(pages.some(({ name }) => name.endsWith(" Setup"))).toBe(false);
const advanced = pages.find(({ name }) => name.endsWith(" Advanced"));
expect(advanced?.items?.filter((item) => "type" in item && item.type === "group")).toHaveLength(4);
expect(advanced?.items?.filter((item) => "action" in item && typeof item.action === "function")).toHaveLength(
1
);
expect(advanced?.page).toBeUndefined();
expect(pages.filter(({ page }) => page !== undefined)).toHaveLength(11);
expect(pages.filter(({ page }) => page !== undefined)).toHaveLength(10);
});
it("keeps simple setup actions on the landing page without a second Setup destination", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const quickSetup = definitions.find(
(item): item is SettingDefinitionGroup => isGroup(item) && item.heading === "🧙‍♂️ Quick Setup"
);
expect(quickSetup?.items?.map(({ name }) => name)).toEqual([
"Connect with Setup URI",
"Rerun Onboarding Wizard",
"Enable LiveSync",
]);
expect(collectPages(definitions).some(({ name }) => name.endsWith(" Setup"))).toBe(false);
});
it("constructs custom page state only when opened and disposes each rendered scope", () => {
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
expect(runtime.components).toHaveLength(0);
const page = general.page();
const page = changeLog.page();
expect(runtime.components).toHaveLength(0);
page.display();
expect(runtime.paneGeneral).toHaveBeenCalledOnce();
expect(runtime.paneChangeLog).toHaveBeenCalledOnce();
expect(runtime.components).toHaveLength(1);
expect(runtime.components[0].load).toHaveBeenCalledOnce();
@@ -192,7 +361,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
});
it("does not run delayed pane work after its page scope has been disposed", async () => {
runtime.paneGeneral.mockImplementation(function (
runtime.paneChangeLog.mockImplementation(function (
this: ObsidianLiveSyncSettingTab,
_paneEl: HTMLElement,
{ addPanel }: Pick<PageFunctions, "addPanel">
@@ -202,12 +371,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
});
});
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
const page = general.page();
const page = changeLog.page();
page.display();
page.hide();
await Promise.resolve();
@@ -216,7 +385,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
});
it("runs a delayed pane callback inside its active scope before a queued hide", async () => {
runtime.paneGeneral.mockImplementation(function (
runtime.paneChangeLog.mockImplementation(function (
this: ObsidianLiveSyncSettingTab,
_paneEl: HTMLElement,
{ addPanel }: Pick<PageFunctions, "addPanel">
@@ -226,12 +395,12 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
});
});
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
const page = general.page();
const page = changeLog.page();
page.display();
queueMicrotask(() => page.hide());
await Promise.resolve();
@@ -242,11 +411,11 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("rebuilds the catalogue when an externally loaded setting changes page visibility", () => {
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
general.page().display();
changeLog.page().display();
tab.core.settings.usePowerUserMode = !tab.editingSettings.usePowerUserMode;
tab.requestReload();
@@ -254,13 +423,22 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
expect(tab.update).toHaveBeenCalledOnce();
});
it("rebuilds the catalogue after an Extra menus feature level is saved", async () => {
const tab = createSettingsTab();
tab.editingSettings.usePowerUserMode = true;
await tab.saveSettings(["usePowerUserMode"]);
expect(tab.update).toHaveBeenCalledOnce();
});
it("rebuilds translated catalogue names when the display language changes externally", () => {
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
general.page().display();
changeLog.page().display();
tab.core.settings.displayLanguage = "ja";
tab.requestReload();
@@ -270,11 +448,11 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("rebuilds the catalogue after accepting an external page-visibility setting over a dirty value", () => {
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
general.page().display();
changeLog.page().display();
tab.initialSettings!.usePowerUserMode = false;
tab.editingSettings.usePowerUserMode = true;
tab.core.settings.usePowerUserMode = true;
@@ -298,30 +476,30 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("does not reopen a custom page when a catalogue update has already hidden it", () => {
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
const page = general.page();
const page = changeLog.page();
page.display();
vi.mocked(tab.update).mockImplementation(() => page.hide());
tab.requestCatalogueRefresh();
expect(runtime.paneGeneral).toHaveBeenCalledOnce();
expect(runtime.paneChangeLog).toHaveBeenCalledOnce();
});
it("keeps saved-setting effects owned by the tab after a custom page closes", async () => {
runtime.paneGeneral.mockImplementation(function (this: ObsidianLiveSyncSettingTab) {
runtime.paneChangeLog.mockImplementation(function (this: ObsidianLiveSyncSettingTab) {
this.addOnSaved("displayLanguage", runtime.savedEffect);
});
const tab = createSettingsTab();
const general = tab.getSettingDefinitions().filter(isPage)[2];
if (!general?.page) {
throw new Error("General custom page is unavailable");
const changeLog = findPage(tab, "Change Log");
if (!changeLog.page) {
throw new Error("Change Log custom page is unavailable");
}
const page = general.page();
const page = changeLog.page();
page.display();
page.hide();
tab.editingSettings.displayLanguage = "ja";
@@ -32,7 +32,14 @@ import {
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import { fireAndForget, yieldNextAnimationFrame } from "octagonal-wheels/promises";
import { EVENT_REQUEST_RELOAD_SETTING_TAB, eventHub } from "@/common/events.ts";
import {
EVENT_ON_UNRESOLVED_ERROR,
EVENT_REQUEST_COPY_SETUP_URI,
EVENT_REQUEST_OPEN_SETUP_URI,
EVENT_REQUEST_RELOAD_SETTING_TAB,
EVENT_REQUEST_SHOW_SETUP_QR,
eventHub,
} from "@/common/events.ts";
import {
enableOnly,
// findAttrFromParent,
@@ -54,12 +61,21 @@ import { MinioStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/replicati
import { closeObsidianSettings } from "@/common/obsidianSettings.ts";
import {
createAdvancedSettingDefinitionGroups,
createExtraMenuSettingDefinitions,
createGeneralSettingDefinitionGroups,
createSettingsPageCatalogue,
type SettingsPageEntry,
} from "./SettingsPageCatalogue.ts";
import { createAdvancedSettingSpecGroups } from "./AdvancedSettingSpecs.ts";
import { isValidSettingSpecValue, type SettingSpec } from "./SettingSpec.ts";
import type { SettingDefinitionAction, SettingDefinitionItem, SettingDefinitionPage } from "obsidian";
import type {
SettingDefinitionAction,
SettingDefinitionGroup,
SettingDefinitionItem,
SettingDefinitionPage,
} from "obsidian";
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
// For creating a document
// const toc = new Set<string>();
@@ -312,6 +328,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
eventHub.onEvent(EVENT_REQUEST_RELOAD_SETTING_TAB, () => {
this.requestReload();
});
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
this.addOnSaved("showStatusOnEditor", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
this.addOnSaved("networkWarningStyle", () => eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR));
this.addOnSaved("useAdvancedMode", () => this.requestCatalogueRefresh());
this.addOnSaved("usePowerUserMode", () => this.requestCatalogueRefresh());
this.addOnSaved("useEdgeCaseMode", () => this.requestCatalogueRefresh());
}
async testConnection(settingOverride: Partial<ObsidianLiveSyncSettings> = {}): Promise<void> {
@@ -339,6 +361,29 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
closeObsidianSettings(this.plugin.app);
}
requestOpenSetupURI(): void {
this.closeSetting();
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETUP_URI);
}
async rerunOnboardingWizard(): Promise<void> {
await this.core.getModule(SetupManager).startOnBoarding();
}
async enableLiveSyncFromSettings(): Promise<void> {
this.editingSettings.isConfigured = true;
await this.saveAllDirtySettings();
this.services.appLifecycle.askRestart();
}
requestCopySetupURI(): void {
eventHub.emitEvent(EVENT_REQUEST_COPY_SETUP_URI);
}
requestShowSetupQRCode(): void {
eventHub.emitEvent(EVENT_REQUEST_SHOW_SETUP_QR);
}
handleElement(element: HTMLElement, func: OnUpdateFunc) {
const updateFunc = ((element, func) => {
const prev = {} as OnUpdateResult;
@@ -409,7 +454,15 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
key === "displayLanguage" ||
key === "useAdvancedMode" ||
key === "usePowerUserMode" ||
key === "useEdgeCaseMode"
key === "useEdgeCaseMode" ||
key === "isConfigured" ||
key === "liveSync" ||
key === "periodicReplication" ||
key === "syncOnSave" ||
key === "syncOnEditorSave" ||
key === "syncOnStart" ||
key === "syncOnFileOpen" ||
key === "syncAfterMerge"
);
}
@@ -564,9 +617,16 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
}
private getDeclarativeSettingSpec(key: string): SettingSpec {
const spec = createAdvancedSettingSpecGroups({
isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB),
})
const spec = [
...createGeneralSettingSpecGroups({
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
}),
createExtraMenuSettingSpecGroup(),
...createAdvancedSettingSpecGroups({
isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB),
}),
]
.flatMap((group) => group.items)
.find((candidate) => candidate.key === key);
if (!spec) {
@@ -697,28 +757,165 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
throw new Error("Custom settings pages require Obsidian 1.13.0 or later");
}
private createDeclarativePage(entry: SettingsPageEntry): SettingDefinitionPage {
const page: SettingDefinitionPage = {
type: "page",
name: `${entry.icon} ${entry.name()}`,
visible: () => this.isPageVisible(entry.level),
};
if (entry.content === "native") {
page.items = [
this.createRebuildRequiredAction(),
...createAdvancedSettingDefinitionGroups({
isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB),
}),
];
} else {
page.page = () => this.createCustomSettingPage(entry);
}
return page;
}
private createGeneralSettingsGroup(): SettingDefinitionGroup {
const groups = createGeneralSettingDefinitionGroups({
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
});
const [appearance, logging] = groups;
if (!appearance || !logging) {
throw new Error("General settings must define Appearance and Logging groups");
}
return this.createPageGroup(`⚙️ ${$msg("obsidianLiveSyncSettingTab.panelGeneralSettings")}`, [
{
type: "page",
name: `🎨 ${appearance.heading}`,
items: appearance.items,
},
{
type: "page",
name: `📝 ${logging.heading}`,
items: logging.items,
},
this.createExtraMenusPage(),
]);
}
private createExtraMenusPage(): SettingDefinitionPage {
return {
type: "page",
name: `🎚️ ${$msg("obsidianLiveSyncSettingTab.titleExtraMenus")}`,
items: createExtraMenuSettingDefinitions(),
};
}
private createQuickSetupGroup(): SettingDefinitionGroup {
return {
type: "group",
heading: `🧙‍♂️ ${$msg("obsidianLiveSyncSettingTab.titleQuickSetup")}`,
items: [
{
name: $msg("obsidianLiveSyncSettingTab.nameConnectSetupURI"),
desc: $msg("obsidianLiveSyncSettingTab.descConnectSetupURI"),
action: () => this.requestOpenSetupURI(),
},
{
name: $msg("Rerun Onboarding Wizard"),
desc: $msg("Rerun the onboarding wizard to set up Self-hosted LiveSync again."),
action: () => fireAndForget(async () => await this.rerunOnboardingWizard()),
},
{
name: $msg("obsidianLiveSyncSettingTab.nameEnableLiveSync"),
desc: $msg("obsidianLiveSyncSettingTab.descEnableLiveSync"),
visible: () => !this.isConfiguredAs("isConfigured", true),
action: () => fireAndForget(async () => await this.enableLiveSyncFromSettings()),
},
],
};
}
private createSynchronisationGroup(pages: SettingDefinitionPage[]): SettingDefinitionGroup {
return this.createPageGroup(`🔄 ${$msg("obsidianLiveSyncSettingTab.titleSynchronisation")}`, pages);
}
private createPageGroup(
heading: string,
pages: SettingDefinitionPage[],
visible?: () => boolean
): SettingDefinitionGroup {
return {
type: "group",
heading,
items: pages,
...(visible ? { visible } : {}),
};
}
private createSetupOtherDevicesGroup(): SettingDefinitionGroup {
return {
type: "group",
heading: `📲 ${$msg("obsidianLiveSyncSettingTab.titleSetupOtherDevices")}`,
visible: () => this.isConfiguredAs("isConfigured", true),
items: [
{
name: $msg("obsidianLiveSyncSettingTab.nameCopySetupURI"),
desc: $msg("obsidianLiveSyncSettingTab.descCopySetupURI"),
action: () => this.requestCopySetupURI(),
},
{
name: $msg("Setup.ShowQRCode"),
desc: $msg("Setup.ShowQRCode.Desc"),
action: () => this.requestShowSetupQRCode(),
},
],
};
}
override getSettingDefinitions(): SettingDefinitionItem[] {
if (!this.supportsDeclarativeSettings()) {
return [];
}
return createSettingsPageCatalogue().map((entry): SettingDefinitionPage => {
const page: SettingDefinitionPage = {
type: "page",
name: `${entry.icon} ${entry.name()}`,
visible: () => this.isPageVisible(entry.level),
};
if (entry.content === "native") {
page.items = [
this.createRebuildRequiredAction(),
...createAdvancedSettingDefinitionGroups({
isCouchDB: () => this.isConfiguredAs("remoteType", REMOTE_COUCHDB),
}),
];
} else {
page.page = () => this.createCustomSettingPage(entry);
const catalogue = createSettingsPageCatalogue();
const getPage = (id: string): SettingDefinitionPage => {
const entry = catalogue.find((candidate) => candidate.id === id);
if (!entry) {
throw new Error(`Unknown settings page: ${id}`);
}
return page;
});
return this.createDeclarativePage(entry);
};
const synchronisation = this.createSynchronisationGroup([
getPage("remote-configuration"),
getPage("synchronisation"),
]);
const generalSettings = this.createGeneralSettingsGroup();
const quickSetup = this.createQuickSetupGroup();
const setupOtherDevices = this.createSetupOtherDevicesGroup();
const maintenance = this.createPageGroup(
`🛠️ ${$msg("obsidianLiveSyncSettingTab.titleMaintenanceAndRecovery")}`,
[getPage("maintenance"), getPage("hatch")]
);
const extraFeatures = this.createPageGroup(
`🧩 ${$msg("obsidianLiveSyncSettingTab.titleExtraFeaturesGroup")}`,
[getPage("selector"), getPage("customisation-sync")],
() => this.isPageVisible(LEVEL_ADVANCED)
);
const advancedSettings = this.createPageGroup(
`🔧 ${$msg("obsidianLiveSyncSettingTab.titleAdvancedSettings")}`,
[getPage("advanced"), getPage("power-users"), getPage("patches")],
() =>
this.isPageVisible(LEVEL_ADVANCED) ||
this.isPageVisible(LEVEL_POWER_USER) ||
this.isPageVisible(LEVEL_EDGE_CASE)
);
const helpAndInformation = this.createPageGroup(
`${$msg("obsidianLiveSyncSettingTab.titleHelpAndInformation")}`,
[getPage("help"), getPage("change-log")]
);
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation];
if (this.isAnySyncEnabled()) {
return [synchronisation, generalSettings, quickSetup, ...laterGroups];
}
return [quickSetup, synchronisation, generalSettings, ...laterGroups];
}
private beginRenderScope(refresh: () => void): Component {
@@ -27,9 +27,14 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", () => ({
},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_ON_UNRESOLVED_ERROR: "on-unresolved-error",
EVENT_REQUEST_COPY_SETUP_URI: "request-copy-setup-uri",
EVENT_REQUEST_OPEN_SETUP_URI: "request-open-setup-uri",
EVENT_REQUEST_RELOAD_SETTING_TAB: "request-reload-setting-tab",
eventHub: { onEvent: vi.fn() },
EVENT_REQUEST_SHOW_SETUP_QR: "request-show-setup-qr",
eventHub: { emitEvent: vi.fn(), onEvent: vi.fn() },
}));
vi.mock("@/modules/features/SetupManager.ts", () => ({ SetupManager: class {} }));
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => negotiationMocks);
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
LiveSyncCouchDBReplicator: class {},
@@ -42,7 +47,8 @@ vi.mock("./SettingPane.ts", () => ({
visibleOnly: vi.fn(() => vi.fn()),
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() }));
vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() }));
vi.mock("./PaneHelp.ts", () => ({ paneHelp: vi.fn() }));
vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() }));
vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() }));
vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() }));
@@ -1,46 +1,22 @@
import { $msg, $t } from "@/common/translation";
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts";
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
export function paneGeneral(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleAppearance")).then((paneEl) => {
const languages = Object.fromEntries([
// ["", $msg("obsidianLiveSyncSettingTab.defaultLanguage")],
...SUPPORTED_I18N_LANGS.map((e) => [e, $t(`lang-${e}`)]),
]) as Record<I18N_LANGS, string>;
new Setting(paneEl).autoWireDropDown("displayLanguage", {
options: languages,
});
this.addOnSaved("displayLanguage", () => this.requestCatalogueRefresh());
new Setting(paneEl).autoWireToggle("showStatusOnEditor");
this.addOnSaved("showStatusOnEditor", () => {
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
});
new Setting(paneEl).autoWireToggle("showOnlyIconsOnEditor", {
onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)),
});
new Setting(paneEl).autoWireToggle("showStatusOnStatusbar");
new Setting(paneEl).autoWireToggle("hideFileWarningNotice");
new Setting(paneEl).autoWireDropDown("networkWarningStyle", {
options: {
[NetworkWarningStyles.BANNER]: "Show full banner",
[NetworkWarningStyles.ICON]: "Show icon only",
[NetworkWarningStyles.HIDDEN]: "Hide completely",
},
});
this.addOnSaved("networkWarningStyle", () => {
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
});
});
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleLogging")).then((paneEl) => {
new Setting(paneEl).autoWireToggle("lessInformationInLog");
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
import { renderLegacySettingSpec } from "./SettingSpec.ts";
new Setting(paneEl).autoWireToggle("showVerboseLog", {
onUpdate: visibleOnly(() => this.isConfiguredAs("lessInformationInLog", false)),
export function paneGeneral(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
const groups = [
...createGeneralSettingSpecGroups({
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
}),
createExtraMenuSettingSpecGroup(),
];
for (const group of groups) {
void addPanel(paneEl, group.heading).then((panelEl) => {
for (const spec of group.items) {
renderLegacySettingSpec(new Setting(panelEl), spec);
}
});
});
}
}
@@ -0,0 +1,102 @@
import { MarkdownRenderer, request } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import { fireAndForget } from "octagonal-wheels/promises";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
/** Render the online help and troubleshooting browser. */
export function paneHelp(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleOnlineTips")).then((panelEl) => {
const lifetimeComponent = this.lifetimeComponent;
let pageDisposed = false;
lifetimeComponent.register(() => {
pageDisposed = true;
});
const repo = "vrtmrz/obsidian-livesync";
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
this.createEl(panelEl, "div", "", (el) => {
el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
anchor.target = "_blank";
anchor.rel = "noopener";
});
});
const troubleShootEl = this.createEl(panelEl, "div", {
text: "",
cls: "sls-troubleshoot-preview",
});
const loadMarkdownPage = async (pathAll: string, basePathParam: string = "") => {
troubleShootEl.setCssStyles({ minHeight: troubleShootEl.clientHeight + "px" });
troubleShootEl.empty();
const fullPath = pathAll.startsWith("/") ? pathAll : `${basePathParam}/${pathAll}`;
const directoryArr = fullPath.split("/");
const filename = directoryArr.pop();
const basePath = directoryArr.join("/");
let remoteTroubleShootMDSrc = "";
try {
remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`);
} catch (ex) {
const err = LiveSyncError.fromError(ex);
remoteTroubleShootMDSrc = `${$msg("obsidianLiveSyncSettingTab.logErrorOccurred")}\n${err.toString()}`;
}
if (pageDisposed) return;
const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace(
/\((.*?(.png)|(.jpg))\)/g,
`(${rawRepoURI}${basePath}/$1)`
);
await MarkdownRenderer.render(
this.plugin.app,
`<a class='sls-troubleshoot-anchor'></a> [${$msg("obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting")}](${topPath}) [${$msg("obsidianLiveSyncSettingTab.linkPageTop")}](${filename})\n\n${remoteTroubleShootMD}`,
troubleShootEl,
`${rawRepoURI}`,
lifetimeComponent
);
if (pageDisposed) return;
troubleShootEl.querySelector<HTMLAnchorElement>(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({
position: "sticky",
top: "-1em",
backgroundColor: "var(--modal-background)",
});
troubleShootEl.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((anchorEl) => {
anchorEl.addEventListener("click", (evt) => {
fireAndForget(async () => {
const uri = anchorEl.getAttr("data-href");
if (!uri) return;
if (uri.startsWith("#")) {
evt.preventDefault();
const elements = Array.from(
troubleShootEl.querySelectorAll<HTMLHeadingElement>("[data-heading]")
);
const target = elements.find(
(element) =>
element.getAttr("data-heading")?.toLowerCase().split(" ").join("-") ===
uri.substring(1).toLowerCase()
);
if (target) {
target.setCssStyles({ scrollMargin: "3em" });
target.scrollIntoView({
behavior: "instant",
block: "start",
});
}
} else {
evt.preventDefault();
await loadMarkdownPage(uri, basePath);
troubleShootEl.setCssStyles({ scrollMargin: "1em" });
troubleShootEl.scrollIntoView({
behavior: "instant",
block: "start",
});
}
});
});
});
troubleShootEl.setCssStyles({ minHeight: "" });
};
void loadMarkdownPage(topPath);
});
}
@@ -1,4 +1,9 @@
import { EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events.ts";
import { $msg } from "@/common/translation";
import {
createCoreSettingsAfterFullReset,
createEditingSettingsAfterFullReset,
} from "@/serviceFeatures/setupObsidian/settingsReset.ts";
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils";
@@ -369,6 +374,30 @@ export function paneMaintenance(
});
void addPanel(paneEl, "Reset").then((paneEl) => {
new Setting(paneEl)
.setName($msg("obsidianLiveSyncSettingTab.nameDiscardSettings"))
.addButton((button) => {
setButtonDestructiveState(button)
.setButtonText($msg("obsidianLiveSyncSettingTab.btnDiscard"))
.onClick(async () => {
if (
(await this.core.confirm.askYesNoDialog(
$msg("obsidianLiveSyncSettingTab.msgDiscardConfirmation"),
{ defaultOption: "No" }
)) !== "yes"
) {
return;
}
this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings);
await this.saveAllDirtySettings();
this.core.settings = createCoreSettingsAfterFullReset();
await this.services.setting.saveSettingData();
await this.services.database.resetDatabase();
this.services.appLifecycle.askRestart();
});
})
.addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true)));
new Setting(paneEl)
.setName("Delete local database to reset or uninstall Self-hosted LiveSync")
.addButton((button) =>
@@ -0,0 +1,65 @@
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { visibleOnly, type PageFunctions } from "./SettingPane.ts";
/** Render setup actions in the pane-based settings interface used before Obsidian 1.13. */
export function paneQuickSetup(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
{ addPanel }: PageFunctions
): void {
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleQuickSetup")).then((panelEl) => {
new Setting(panelEl)
.setName($msg("obsidianLiveSyncSettingTab.nameConnectSetupURI"))
.setDesc($msg("obsidianLiveSyncSettingTab.descConnectSetupURI"))
.addButton((button) => {
button.setButtonText($msg("obsidianLiveSyncSettingTab.btnUse")).onClick(() => {
this.requestOpenSetupURI();
});
});
new Setting(panelEl)
.setName($msg("Rerun Onboarding Wizard"))
.setDesc($msg("Rerun the onboarding wizard to set up Self-hosted LiveSync again."))
.addButton((button) => {
button.setButtonText($msg("Rerun Wizard")).onClick(async () => {
await this.rerunOnboardingWizard();
});
});
new Setting(panelEl)
.setName($msg("obsidianLiveSyncSettingTab.nameEnableLiveSync"))
.setDesc($msg("obsidianLiveSyncSettingTab.descEnableLiveSync"))
.addOnUpdate(visibleOnly(() => !this.isConfiguredAs("isConfigured", true)))
.addButton((button) => {
button.setButtonText($msg("obsidianLiveSyncSettingTab.btnEnable")).onClick(async () => {
await this.enableLiveSyncFromSettings();
});
});
});
void addPanel(
paneEl,
`📲 ${$msg("obsidianLiveSyncSettingTab.titleSetupOtherDevices")}`,
undefined,
visibleOnly(() => this.isConfiguredAs("isConfigured", true))
).then((panelEl) => {
new Setting(panelEl)
.setName($msg("obsidianLiveSyncSettingTab.nameCopySetupURI"))
.setDesc($msg("obsidianLiveSyncSettingTab.descCopySetupURI"))
.addButton((button) => {
button.setButtonText($msg("obsidianLiveSyncSettingTab.btnCopy")).onClick(() => {
this.requestCopySetupURI();
});
});
new Setting(panelEl)
.setName($msg("Setup.ShowQRCode"))
.setDesc($msg("Setup.ShowQRCode.Desc"))
.addButton((button) => {
button.setButtonText($msg("Setup.ShowQRCode")).onClick(() => {
this.requestShowSetupQRCode();
});
});
});
}
@@ -1,218 +0,0 @@
import { MarkdownRenderer } from "@/deps.ts";
import { $msg } from "@/common/translation";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import {
EVENT_REQUEST_COPY_SETUP_URI,
EVENT_REQUEST_OPEN_SETUP_URI,
EVENT_REQUEST_SHOW_SETUP_QR,
eventHub,
} from "@/common/events.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import { setButtonDestructiveState, visibleOnly, type PageFunctions } from "./SettingPane.ts";
import { request } from "@/deps.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError";
import {
createCoreSettingsAfterFullReset,
createEditingSettingsAfterFullReset,
} from "@/serviceFeatures/setupObsidian/settingsReset.ts";
export function paneSetup(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
{ addPanel, addPane }: PageFunctions
): void {
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleQuickSetup")).then((paneEl) => {
new Setting(paneEl)
.setName($msg("obsidianLiveSyncSettingTab.nameConnectSetupURI"))
.setDesc($msg("obsidianLiveSyncSettingTab.descConnectSetupURI"))
.addButton((text) => {
text.setButtonText($msg("obsidianLiveSyncSettingTab.btnUse")).onClick(() => {
this.closeSetting();
eventHub.emitEvent(EVENT_REQUEST_OPEN_SETUP_URI);
});
});
new Setting(paneEl)
.setName($msg("Rerun Onboarding Wizard"))
.setDesc($msg("Rerun the onboarding wizard to set up Self-hosted LiveSync again."))
.addButton((text) => {
text.setButtonText($msg("Rerun Wizard")).onClick(async () => {
const setupManager = this.core.getModule(SetupManager);
await setupManager.startOnBoarding();
});
});
new Setting(paneEl)
.setName($msg("obsidianLiveSyncSettingTab.nameEnableLiveSync"))
.setDesc($msg("obsidianLiveSyncSettingTab.descEnableLiveSync"))
.addOnUpdate(visibleOnly(() => !this.isConfiguredAs("isConfigured", true)))
.addButton((text) => {
text.setButtonText($msg("obsidianLiveSyncSettingTab.btnEnable")).onClick(async () => {
this.editingSettings.isConfigured = true;
await this.saveAllDirtySettings();
this.services.appLifecycle.askRestart();
});
});
});
void addPanel(
paneEl,
$msg("obsidianLiveSyncSettingTab.titleSetupOtherDevices"),
undefined,
visibleOnly(() => this.isConfiguredAs("isConfigured", true))
).then((paneEl) => {
new Setting(paneEl)
.setName($msg("obsidianLiveSyncSettingTab.nameCopySetupURI"))
.setDesc($msg("obsidianLiveSyncSettingTab.descCopySetupURI"))
.addButton((text) => {
text.setButtonText($msg("obsidianLiveSyncSettingTab.btnCopy")).onClick(() => {
// await this.plugin.addOnSetup.command_copySetupURI();
eventHub.emitEvent(EVENT_REQUEST_COPY_SETUP_URI);
});
});
new Setting(paneEl)
.setName($msg("Setup.ShowQRCode"))
.setDesc($msg("Setup.ShowQRCode.Desc"))
.addButton((text) => {
text.setButtonText($msg("Setup.ShowQRCode")).onClick(() => {
eventHub.emitEvent(EVENT_REQUEST_SHOW_SETUP_QR);
});
});
});
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleReset")).then((paneEl) => {
new Setting(paneEl)
.setName($msg("obsidianLiveSyncSettingTab.nameDiscardSettings"))
.addButton((text) => {
setButtonDestructiveState(text)
.setButtonText($msg("obsidianLiveSyncSettingTab.btnDiscard"))
.onClick(async () => {
if (
(await this.core.confirm.askYesNoDialog(
$msg("obsidianLiveSyncSettingTab.msgDiscardConfirmation"),
{ defaultOption: "No" }
)) == "yes"
) {
this.editingSettings = createEditingSettingsAfterFullReset(this.editingSettings);
await this.saveAllDirtySettings();
this.core.settings = createCoreSettingsAfterFullReset();
await this.services.setting.saveSettingData();
await this.services.database.resetDatabase();
// await this.plugin.initializeDatabase();
this.services.appLifecycle.askRestart();
}
});
})
.addOnUpdate(visibleOnly(() => this.isConfiguredAs("isConfigured", true)));
});
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleExtraFeatures")).then((paneEl) => {
new Setting(paneEl).autoWireToggle("useAdvancedMode");
new Setting(paneEl).autoWireToggle("usePowerUserMode");
new Setting(paneEl).autoWireToggle("useEdgeCaseMode");
this.addOnSaved("useAdvancedMode", () => this.requestCatalogueRefresh());
this.addOnSaved("usePowerUserMode", () => this.requestCatalogueRefresh());
this.addOnSaved("useEdgeCaseMode", () => this.requestCatalogueRefresh());
});
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleOnlineTips")).then((paneEl) => {
const lifetimeComponent = this.lifetimeComponent;
let pageDisposed = false;
lifetimeComponent.register(() => {
pageDisposed = true;
});
// this.createEl(paneEl, "h3", { text: $msg("obsidianLiveSyncSettingTab.titleOnlineTips") });
const repo = "vrtmrz/obsidian-livesync";
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
this.createEl(paneEl, "div", "", (el) => {
el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
anchor.target = "_blank";
anchor.rel = "noopener";
});
});
const troubleShootEl = this.createEl(paneEl, "div", {
text: "",
cls: "sls-troubleshoot-preview",
});
const loadMarkdownPage = async (pathAll: string, basePathParam: string = "") => {
troubleShootEl.setCssStyles({ minHeight: troubleShootEl.clientHeight + "px" });
troubleShootEl.empty();
const fullPath = pathAll.startsWith("/") ? pathAll : `${basePathParam}/${pathAll}`;
const directoryArr = fullPath.split("/");
const filename = directoryArr.pop();
const directly = directoryArr.join("/");
const basePath = directly;
let remoteTroubleShootMDSrc = "";
try {
remoteTroubleShootMDSrc = await request(`${rawRepoURI}${basePath}/${filename}`);
} catch (ex) {
const err = LiveSyncError.fromError(ex);
remoteTroubleShootMDSrc = `${$msg("obsidianLiveSyncSettingTab.logErrorOccurred")}\n${err.toString()}`;
}
if (pageDisposed) return;
const remoteTroubleShootMD = remoteTroubleShootMDSrc.replace(
/\((.*?(.png)|(.jpg))\)/g,
`(${rawRepoURI}${basePath}/$1)`
);
// Render markdown
await MarkdownRenderer.render(
this.plugin.app,
`<a class='sls-troubleshoot-anchor'></a> [${$msg("obsidianLiveSyncSettingTab.linkTipsAndTroubleshooting")}](${topPath}) [${$msg("obsidianLiveSyncSettingTab.linkPageTop")}](${filename})\n\n${remoteTroubleShootMD}`,
troubleShootEl,
`${rawRepoURI}`,
lifetimeComponent
);
if (pageDisposed) return;
// Menu
troubleShootEl.querySelector<HTMLAnchorElement>(".sls-troubleshoot-anchor")?.parentElement?.setCssStyles({
position: "sticky",
top: "-1em",
backgroundColor: "var(--modal-background)",
});
// Trap internal links.
troubleShootEl.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((anchorEl) => {
anchorEl.addEventListener("click", (evt) => {
fireAndForget(async () => {
const uri = anchorEl.getAttr("data-href");
if (!uri) return;
if (uri.startsWith("#")) {
evt.preventDefault();
const elements = Array.from(
troubleShootEl.querySelectorAll<HTMLHeadingElement>("[data-heading]")
);
const p = elements.find(
(e) =>
e.getAttr("data-heading")?.toLowerCase().split(" ").join("-") ==
uri.substring(1).toLowerCase()
);
if (p) {
p.setCssStyles({ scrollMargin: "3em" });
p.scrollIntoView({
behavior: "instant",
block: "start",
});
}
} else {
evt.preventDefault();
await loadMarkdownPage(uri, basePath);
troubleShootEl.setCssStyles({ scrollMargin: "1em" });
troubleShootEl.scrollIntoView({
behavior: "instant",
block: "start",
});
}
});
});
});
troubleShootEl.setCssStyles({ minHeight: "" });
};
void loadMarkdownPage(topPath);
});
}
@@ -95,6 +95,7 @@ export function paneSyncSettings(
await this.saveAllDirtySettings();
await this.services.control.applySettings();
this.requestCatalogueRefresh();
});
});
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleSynchronizationMethod")).then((paneEl) => {
@@ -129,6 +130,7 @@ export function paneSyncSettings(
await this.saveSettings(["liveSync", "periodicReplication"]);
await this.services.control.applySettings();
this.requestCatalogueRefresh();
});
new Setting(paneEl).autoWireNumeric("periodicReplicationInterval", {
@@ -144,6 +146,15 @@ export function paneSyncSettings(
new Setting(paneEl).autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync });
new Setting(paneEl).autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync });
new Setting(paneEl).autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync });
for (const key of [
"syncOnSave",
"syncOnEditorSave",
"syncOnFileOpen",
"syncOnStart",
"syncAfterMerge",
] as const) {
this.addOnSaved(key, () => this.requestCatalogueRefresh());
}
// Desktop app only, and only for the sync modes that keep a background replication channel
// (LiveSync and Periodic). Ignored on mobile, where suspending preserves battery. The
// visibility predicate mirrors the runtime guard in ModuleObsidianEvents.
@@ -9,6 +9,7 @@ import {
type SettingSpec,
} from "./SettingSpec.ts";
import { createAdvancedSettingSpecGroups } from "./AdvancedSettingSpecs.ts";
import { createExtraMenuSettingSpecGroup, createGeneralSettingSpecGroups } from "./GeneralSettingSpecs.ts";
const rangeMessages = {
valueShouldBeInRange: ({ min, max }: { min?: number; max?: number }) => `${min ?? "~"}..${max ?? "~"}`,
@@ -74,6 +75,52 @@ describe("Advanced setting specifications", () => {
});
});
describe("General setting specifications", () => {
it("shares every General and Logging control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
]);
});
it("keeps the three feature-level controls together under Extra menus", () => {
const group = createExtraMenuSettingSpecGroup();
expect(group.heading).toBe("Extra menus");
expect(group.items.map(({ key }) => key)).toEqual(["useAdvancedMode", "usePowerUserMode", "useEdgeCaseMode"]);
});
it("retains the two conditional visibility rules", () => {
let editorDetails = false;
let verboseLog = false;
const specs = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => editorDetails,
showVerboseLog: () => verboseLog,
}).flatMap(({ items }) => items);
const editorIcons = specs.find(({ key }) => key === "showOnlyIconsOnEditor");
const verbose = specs.find(({ key }) => key === "showVerboseLog");
expect(editorIcons?.visible?.()).toBe(false);
expect(verbose?.visible?.()).toBe(false);
editorDetails = true;
verboseLog = true;
expect(editorIcons?.visible?.()).toBe(true);
expect(verbose?.visible?.()).toBe(true);
});
});
describe("SettingSpec conversion", () => {
it("maps metadata and a toggle to an Obsidian definition without importing the runtime API", () => {
const visible = vi.fn(() => true);
@@ -11,17 +11,23 @@ import { toObsidianSettingDefinition, type PersistedSettingKey, type SettingSpec
import { getConfig } from "./settingConstants.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
import {
createExtraMenuSettingSpecGroup,
createGeneralSettingSpecGroups,
type GeneralSettingSpecContext,
} from "./GeneralSettingSpecs.ts";
import { paneAdvanced } from "./PaneAdvanced.ts";
import { paneChangeLog } from "./PaneChangeLog.ts";
import { paneCustomisationSync } from "./PaneCustomisationSync.ts";
import { paneGeneral } from "./PaneGeneral.ts";
import { paneHatch } from "./PaneHatch.ts";
import { paneMaintenance } from "./PaneMaintenance.ts";
import { paneHelp } from "./PaneHelp.ts";
import { panePatches } from "./PanePatches.ts";
import { panePowerUsers } from "./PanePowerUsers.ts";
import { paneRemoteConfig } from "./PaneRemoteConfig.ts";
import { paneSelector } from "./PaneSelector.ts";
import { paneSetup } from "./PaneSetup.ts";
import { paneQuickSetup } from "./PaneQuickSetup.ts";
import { paneSyncSettings } from "./PaneSyncSettings.ts";
/** The existing pane renderer used by the imperative settings tab and custom pages. */
@@ -62,13 +68,13 @@ export function createSettingsPageCatalogue(): SettingsPageEntry[] {
legacy: paneChangeLog,
},
{
id: "setup",
name: () => $msg("obsidianLiveSyncSettingTab.panelSetup"),
id: "quick-setup",
name: () => $msg("obsidianLiveSyncSettingTab.titleQuickSetup"),
icon: "🧙‍♂️",
order: 110,
level: undefined,
content: "custom",
legacy: paneSetup,
legacy: paneQuickSetup,
},
{
id: "general",
@@ -160,6 +166,15 @@ export function createSettingsPageCatalogue(): SettingsPageEntry[] {
content: "custom",
legacy: paneMaintenance,
},
{
id: "help",
name: () => $msg("obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting"),
icon: "❓",
order: 90,
level: undefined,
content: "custom",
legacy: paneHelp,
},
];
}
@@ -169,7 +184,7 @@ const numberRangeMessage = ({ min, max }: { min?: number; max?: number }): strin
max: max === undefined ? "~" : `${max}`,
});
function toAdvancedSettingDefinition(spec: SettingSpec): ReturnType<typeof toObsidianSettingDefinition> {
function toSettingDefinition(spec: SettingSpec): ReturnType<typeof toObsidianSettingDefinition> {
const metadata = getConfig(spec.key);
if (!metadata) {
throw new Error(`Missing translated setting metadata for ${spec.key}`);
@@ -186,6 +201,22 @@ export function createAdvancedSettingDefinitionGroups(
return createAdvancedSettingSpecGroups(context).map((group) => ({
type: "group",
heading: group.heading,
items: group.items.map(toAdvancedSettingDefinition),
items: group.items.map(toSettingDefinition),
}));
}
/** Convert the shared General specifications to native Obsidian groups. */
export function createGeneralSettingDefinitionGroups(
context: GeneralSettingSpecContext
): SettingDefinitionGroup<PersistedSettingKey>[] {
return createGeneralSettingSpecGroups(context).map((group) => ({
type: "group",
heading: group.heading,
items: group.items.map(toSettingDefinition),
}));
}
/** Convert the Extra menus feature-level controls to native Obsidian settings. */
export function createExtraMenuSettingDefinitions(): ReturnType<typeof toObsidianSettingDefinition>[] {
return createExtraMenuSettingSpecGroup().items.map(toSettingDefinition);
}
@@ -5,7 +5,8 @@ vi.mock("@/common/translation", () => ({
translateLiveSyncMessage: (key: string) => key,
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
vi.mock("./PaneSetup.ts", () => ({ paneSetup: vi.fn() }));
vi.mock("./PaneQuickSetup.ts", () => ({ paneQuickSetup: vi.fn() }));
vi.mock("./PaneHelp.ts", () => ({ paneHelp: vi.fn() }));
vi.mock("./PaneGeneral.ts", () => ({ paneGeneral: vi.fn() }));
vi.mock("./PaneRemoteConfig.ts", () => ({ paneRemoteConfig: vi.fn() }));
vi.mock("./PaneSelector.ts", () => ({ paneSelector: vi.fn() }));
@@ -25,7 +26,7 @@ describe("settings page catalogue", () => {
expect(catalogue.map(({ id }) => id)).toEqual([
"change-log",
"setup",
"quick-setup",
"general",
"remote-configuration",
"synchronisation",
@@ -36,11 +37,18 @@ describe("settings page catalogue", () => {
"power-users",
"patches",
"maintenance",
"help",
]);
expect(new Set(catalogue.map(({ id }) => id)).size).toBe(catalogue.length);
expect(new Set(catalogue.map(({ name }) => name())).size).toBe(catalogue.length);
expect(catalogue.filter(({ content }) => content === "native").map(({ id }) => id)).toEqual(["advanced"]);
expect(catalogue.filter(({ content }) => content === "custom")).toHaveLength(11);
expect(catalogue.filter(({ content }) => content === "custom")).toHaveLength(12);
expect(catalogue.find(({ id }) => id === "quick-setup")?.name()).toBe(
"obsidianLiveSyncSettingTab.titleQuickSetup"
);
expect(catalogue.find(({ id }) => id === "help")?.name()).toBe(
"obsidianLiveSyncSettingTab.titleHelpAndTroubleshooting"
);
});
it("registers each Advanced control key exactly once", () => {