Improved: remote management

This commit is contained in:
vorotamoroz
2026-04-05 16:00:57 +09:00
parent f17f1ecd93
commit d7088be8af
14 changed files with 619 additions and 159 deletions

View File

@@ -162,8 +162,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
result == APPLY_AND_REBUILD ||
result == APPLY_AND_FETCH
) {
this.core.settings = settingToApply;
await this.services.setting.saveSettingData();
await this.services.setting.applyExternalSettings(settingToApply, true);
this.services.setting.clearUsedPassphrase();
if (result == APPLY_ONLY) {
this._log("Loaded settings have been applied!", LOG_LEVEL_NOTICE);
return;

View File

@@ -3,8 +3,10 @@ import {
REMOTE_MINIO,
REMOTE_P2P,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
} from "../../../lib/src/common/types.ts";
import { Menu } from "@/deps.ts";
import { $msg } from "../../../lib/src/common/i18n.ts";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
@@ -24,6 +26,7 @@ import { SetupManager, UserMode } from "../SetupManager.ts";
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
import { activateRemoteConfiguration } from "../../../lib/src/serviceFeatures/remoteConfig.ts";
import { ConnectionStringParser } from "../../../lib/src/common/ConnectionString.ts";
import type { RemoteConfigurationResult } from "../../../lib/src/common/ConnectionString.ts";
import type { RemoteConfiguration } from "../../../lib/src/common/models/setting.type.ts";
import SetupRemote from "../SetupWizard/dialogs/SetupRemote.svelte";
import SetupRemoteCouchDB from "../SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
@@ -67,6 +70,29 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
return ConnectionStringParser.serialize({ type: "couchdb", settings });
}
function setEmojiButton(button: any, emoji: string, tooltip: string) {
button.setButtonText(emoji);
button.setTooltip(tooltip, { delay: 10, placement: "top" });
// button.buttonEl.addClass("clickable-icon");
button.buttonEl.addClass("mod-muted");
return button;
}
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
if (parsed.type === "couchdb") {
try {
const url = new URL(parsed.settings.couchDB_URI);
return `CouchDB ${url.host}`;
} catch {
return "Imported CouchDB";
}
}
if (parsed.type === "s3") {
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
}
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
}
export function paneRemoteConfig(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -237,11 +263,60 @@ export function paneRemoteConfig(
await persistRemoteConfigurations(this.editingSettings.activeConfigurationId === id);
refreshList();
};
const importRemoteConfiguration = async () => {
const importedURI = await this.services.UI.confirm.askString(
"Import connection",
"Paste a connection string",
""
);
if (importedURI === false) {
return;
}
const trimmedURI = importedURI.trim();
if (trimmedURI === "") {
return;
}
let parsed: RemoteConfigurationResult;
try {
parsed = ConnectionStringParser.parse(trimmedURI);
} catch (ex) {
this.services.API.addLog(`Failed to import remote configuration: ${ex}`, LOG_LEVEL_NOTICE);
return;
}
const defaultName = suggestRemoteConfigurationName(parsed);
const name = await this.services.UI.confirm.askString("Remote name", "Display name", defaultName);
if (name === false) {
return;
}
const id = createRemoteConfigurationId();
const configs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations);
configs[id] = {
id,
name: name.trim() || defaultName,
uri: ConnectionStringParser.serialize(parsed),
isEncrypted: false,
};
this.editingSettings.remoteConfigurations = configs;
if (!this.editingSettings.activeConfigurationId) {
this.editingSettings.activeConfigurationId = id;
}
await persistRemoteConfigurations(this.editingSettings.activeConfigurationId === id);
refreshList();
};
actions.addButton((button) =>
button.setButtonText("Add New Connection").onClick(async () => {
setEmojiButton(button, "", "Add new connection").onClick(async () => {
await addRemoteConfiguration();
})
);
actions.addButton((button) =>
setEmojiButton(button, "📥", "Import connection").onClick(async () => {
await importRemoteConfiguration();
})
);
const refreshList = () => {
listContainer.empty();
const configs = this.editingSettings.remoteConfigurations || {};
@@ -256,7 +331,7 @@ export function paneRemoteConfig(
}
row.addButton((btn) =>
btn.setButtonText("Configure").onClick(async () => {
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
const parsed = ConnectionStringParser.parse(config.uri);
const workSettings = createBaseRemoteSettings();
if (parsed.type === "couchdb") {
@@ -284,83 +359,10 @@ export function paneRemoteConfig(
refreshList();
})
);
row.addButton((btn) =>
btn.setButtonText("Rename").onClick(async () => {
const nextName = await this.services.UI.confirm.askString(
"Remote name",
"Display name",
config.name
);
if (nextName === false) {
return;
}
const nextConfigs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations);
nextConfigs[config.id] = {
...config,
name: nextName.trim() || config.name,
};
this.editingSettings.remoteConfigurations = nextConfigs;
await persistRemoteConfigurations();
refreshList();
})
);
row.addButton((btn) =>
btn.setButtonText("Duplicate").onClick(async () => {
const nextName = await this.services.UI.confirm.askString(
"Duplicate remote",
"Display name",
`${config.name} (Copy)`
);
if (nextName === false) {
return;
}
const nextId = createRemoteConfigurationId();
const nextConfigs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations);
nextConfigs[nextId] = {
...config,
id: nextId,
name: nextName.trim() || `${config.name} (Copy)`,
};
this.editingSettings.remoteConfigurations = nextConfigs;
await persistRemoteConfigurations();
refreshList();
})
);
row.addButton((btn) =>
btn
.setButtonText("Delete")
.setWarning()
.onClick(async () => {
const confirmed = await this.services.UI.confirm.askYesNoDialog(
`Delete remote configuration '${config.name}'?`,
{ title: "Delete Remote Configuration", defaultOption: "No" }
);
if (confirmed !== "yes") {
return;
}
const nextConfigs = cloneRemoteConfigurations(
this.editingSettings.remoteConfigurations
);
delete nextConfigs[config.id];
this.editingSettings.remoteConfigurations = nextConfigs;
let syncActiveRemote = false;
if (this.editingSettings.activeConfigurationId === config.id) {
const nextActiveId = Object.keys(nextConfigs)[0] || "";
this.editingSettings.activeConfigurationId = nextActiveId;
syncActiveRemote = nextActiveId !== "";
}
await persistRemoteConfigurations(syncActiveRemote);
refreshList();
})
);
row.addButton((btn) =>
btn
.setButtonText("Activate")
.setButtonText("")
.setTooltip("Activate", { delay: 10, placement: "top" })
.setDisabled(config.id === this.editingSettings.activeConfigurationId)
.onClick(async () => {
this.editingSettings.activeConfigurationId = config.id;
@@ -368,6 +370,97 @@ export function paneRemoteConfig(
refreshList();
})
);
row.addButton((btn) =>
setEmojiButton(btn, "…", "More actions").onClick(() => {
const menu = new Menu()
.addItem((item) => {
item.setTitle("🪪 Rename").onClick(async () => {
const nextName = await this.services.UI.confirm.askString(
"Remote name",
"Display name",
config.name
);
if (nextName === false) {
return;
}
const nextConfigs = cloneRemoteConfigurations(
this.editingSettings.remoteConfigurations
);
nextConfigs[config.id] = {
...config,
name: nextName.trim() || config.name,
};
this.editingSettings.remoteConfigurations = nextConfigs;
await persistRemoteConfigurations();
refreshList();
});
})
.addItem((item) => {
item.setTitle("📤 Export").onClick(async () => {
await this.services.UI.promptCopyToClipboard(
`Remote configuration: ${config.name}`,
config.uri
);
});
})
.addItem((item) => {
item.setTitle("🧬 Duplicate").onClick(async () => {
const nextName = await this.services.UI.confirm.askString(
"Duplicate remote",
"Display name",
`${config.name} (Copy)`
);
if (nextName === false) {
return;
}
const nextId = createRemoteConfigurationId();
const nextConfigs = cloneRemoteConfigurations(
this.editingSettings.remoteConfigurations
);
nextConfigs[nextId] = {
...config,
id: nextId,
name: nextName.trim() || `${config.name} (Copy)`,
};
this.editingSettings.remoteConfigurations = nextConfigs;
await persistRemoteConfigurations();
refreshList();
});
})
.addSeparator()
.addItem((item) => {
item.setTitle("🗑 Delete").onClick(async () => {
const confirmed = await this.services.UI.confirm.askYesNoDialog(
`Delete remote configuration '${config.name}'?`,
{ title: "Delete Remote Configuration", defaultOption: "No" }
);
if (confirmed !== "yes") {
return;
}
const nextConfigs = cloneRemoteConfigurations(
this.editingSettings.remoteConfigurations
);
delete nextConfigs[config.id];
this.editingSettings.remoteConfigurations = nextConfigs;
let syncActiveRemote = false;
if (this.editingSettings.activeConfigurationId === config.id) {
const nextActiveId = Object.keys(nextConfigs)[0] || "";
this.editingSettings.activeConfigurationId = nextActiveId;
syncActiveRemote = nextActiveId !== "";
}
await persistRemoteConfigurations(syncActiveRemote);
refreshList();
});
});
const rect = btn.buttonEl.getBoundingClientRect();
menu.showAtPosition({ x: rect.left, y: rect.bottom });
})
);
}
};
refreshList();

View File

@@ -275,6 +275,10 @@ export class SetupManager extends AbstractModule {
activate: boolean = true,
extra: () => void = () => {}
): Promise<boolean> {
newConf = await this.services.setting.adjustSettings({
...this.settings,
...newConf,
});
let userMode = _userMode;
if (userMode === UserMode.Unknown) {
if (isObjectDifferent(this.settings, newConf, true) === false) {
@@ -368,13 +372,8 @@ export class SetupManager extends AbstractModule {
* @returns Promise that resolves to true if settings applied successfully, false otherwise
*/
async applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode) {
const newSetting = {
...this.core.settings,
...newConf,
};
this.core.settings = newSetting;
this.services.setting.clearUsedPassphrase();
await this.services.setting.saveSettingData();
await this.services.setting.applyExternalSettings(newConf, true);
return true;
}
}

View File

@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "../../lib/src/common/types";
import { SettingService } from "../../lib/src/services/base/SettingService";
import { ServiceContext } from "../../lib/src/services/base/ServiceBase";
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SelectMethodExisting.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/ScanQRCode.svelte", () => ({ default: {} }));
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/SetupRemote.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
vi.mock("../../lib/src/API/processSetting.ts", () => ({
decodeSettingsFromQRCodeData: vi.fn(),
}));
import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts";
import { SetupManager, UserMode } from "./SetupManager";
class TestSettingService extends SettingService<ServiceContext> {
protected setItem(_key: string, _value: string): void {}
protected getItem(_key: string): string {
return "";
}
protected deleteItem(_key: string): void {}
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
return Promise.resolve();
}
protected loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
return Promise.resolve(undefined);
}
}
function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
return {
...DEFAULT_SETTINGS,
remoteConfigurations: {},
activeConfigurationId: "",
remoteType: REMOTE_COUCHDB,
couchDB_URI: "http://localhost:5984",
couchDB_USER: "user",
couchDB_PASSWORD: "password",
couchDB_DBNAME: "vault",
};
}
function createSetupManager() {
const setting = new TestSettingService(new ServiceContext(), {
APIService: {
getSystemVaultName: vi.fn(() => "vault"),
getAppID: vi.fn(() => "app"),
confirm: {
askString: vi.fn(() => Promise.resolve("")),
},
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
} as any,
});
setting.settings = {
...DEFAULT_SETTINGS,
remoteConfigurations: {},
activeConfigurationId: "",
};
vi.spyOn(setting, "saveSettingData").mockResolvedValue();
const dialogManager = {
openWithExplicitCancel: vi.fn(),
open: vi.fn(),
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
UI: {
dialogManager,
},
setting,
} as any;
const core: any = {
_services: services,
rebuilder: {
scheduleRebuild: vi.fn(() => Promise.resolve()),
scheduleFetch: vi.fn(() => Promise.resolve()),
},
};
Object.defineProperty(core, "services", {
get() {
return services;
},
});
Object.defineProperty(core, "settings", {
get() {
return setting.settings;
},
set(value: ObsidianLiveSyncSettings) {
setting.settings = value;
},
});
return {
manager: new SetupManager(core),
setting,
dialogManager,
core,
};
}
describe("SetupManager", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(createLegacyRemoteSetting())
.mockResolvedValueOnce("compatible-existing-user");
const result = await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings");
expect(result).toBe(true);
expect(setting.currentSettings().remoteConfigurations["legacy-couchdb"]?.uri).toContain(
"sls+http://user:password@localhost:5984"
);
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("decodeQR should normalise imported legacy remote settings before applying", async () => {
const { manager, setting, dialogManager } = createSetupManager();
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user");
const result = await manager.decodeQR("qr-data");
expect(result).toBe(true);
expect(decodeSettingsFromQRCodeData).toHaveBeenCalledWith("qr-data");
expect(setting.currentSettings().remoteConfigurations["legacy-couchdb"]?.uri).toContain(
"sls+http://user:password@localhost:5984"
);
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
});