mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-02 22:01:55 +00:00
Improved: remote management
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
157
src/modules/features/SetupManager.unit.spec.ts
Normal file
157
src/modules/features/SetupManager.unit.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user