diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index 87eb268..f9f0427 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -13,6 +13,7 @@ import type { CheckPointInfo } from "./lib/src/replication/journal/JournalSyncTy import type { LiveSyncJournalReplicatorEnv } from "./lib/src/replication/journal/LiveSyncJournalReplicatorEnv"; import type { LiveSyncReplicatorEnv } from "./lib/src/replication/LiveSyncAbstractReplicator"; import { useTargetFilters } from "./lib/src/serviceFeatures/targetFilter"; +import { useRemoteConfigurationMigration } from "./lib/src/serviceFeatures/remoteConfig"; import type { ServiceContext } from "./lib/src/services/base/ServiceBase"; import type { InjectableServiceHub } from "./lib/src/services/InjectableServices"; import { AbstractModule } from "./modules/AbstractModule"; @@ -272,6 +273,8 @@ export class LiveSyncBaseCore< useTargetFilters(this); // enable target filter feature. usePrepareDatabaseForUse(this); + // Migration to multiple remote configurations + useRemoteConfigurationMigration(this); } } diff --git a/src/apps/webapp/main.ts b/src/apps/webapp/main.ts index 67db1bd..2d96c83 100644 --- a/src/apps/webapp/main.ts +++ b/src/apps/webapp/main.ts @@ -14,6 +14,7 @@ import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner"; import { useRedFlagFeatures } from "@/serviceFeatures/redFlag"; import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize"; import { useSetupURIFeature } from "@lib/serviceFeatures/setupObsidian/setupUri"; +import { useRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; import { SetupManager } from "@/modules/features/SetupManager"; import { useSetupManagerHandlersFeature } from "@/serviceFeatures/setupObsidian/setupManagerHandlers"; import { useP2PReplicatorCommands } from "@/lib/src/replication/trystero/useP2PReplicatorCommands"; @@ -132,6 +133,7 @@ class LiveSyncWebApp { useOfflineScanner(core); useRedFlagFeatures(core); useCheckRemoteSize(core); + useRemoteConfiguration(core); const replicator = useP2PReplicatorFeature(core); useP2PReplicatorCommands(core, replicator); const setupManager = core.getModule(SetupManager); diff --git a/src/lib b/src/lib index c6229cd..d14de2d 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit c6229cd14a23bc871077349ff0377b2def1c73ae +Subproject commit d14de2d8fc5b712354d30772a2422b0599916883 diff --git a/src/main.ts b/src/main.ts index ab89820..a07ce40 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ import { SetupManager } from "./modules/features/SetupManager.ts"; import { ModuleMigration } from "./modules/essential/ModuleMigration.ts"; import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts"; import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner.ts"; +import { useRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig.ts"; import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize.ts"; import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts"; import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts"; @@ -174,6 +175,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const curriedFeature = () => featuresInitialiser(core); core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); const setupManager = core.getModule(SetupManager); + + useRemoteConfiguration(core); + useSetupProtocolFeature(core, setupManager); useSetupQRCodeFeature(core); useSetupURIFeature(core); diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index 216fa47..e3e3883 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -2,6 +2,7 @@ import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P, + DEFAULT_SETTINGS, type ObsidianLiveSyncSettings, } from "../../../lib/src/common/types.ts"; import { $msg } from "../../../lib/src/common/i18n.ts"; @@ -21,6 +22,13 @@ import { import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types.ts"; 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 { RemoteConfiguration } from "../../../lib/src/common/models/setting.type.ts"; +import SetupRemote from "../SetupWizard/dialogs/SetupRemote.svelte"; +import SetupRemoteCouchDB from "../SetupWizard/dialogs/SetupRemoteCouchDB.svelte"; +import SetupRemoteBucket from "../SetupWizard/dialogs/SetupRemoteBucket.svelte"; +import SetupRemoteP2P from "../SetupWizard/dialogs/SetupRemoteP2P.svelte"; function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings { const workObj = { ...editingSettings } as ObsidianLiveSyncSettings; @@ -39,17 +47,31 @@ const toggleActiveSyncClass = (el: HTMLElement, isActive: () => boolean) => { return {}; }; +function createRemoteConfigurationId(): string { + return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function cloneRemoteConfigurations( + configs: Record | undefined +): Record { + return Object.fromEntries(Object.entries(configs || {}).map(([id, config]) => [id, { ...config }])); +} + +function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string { + if (settings.remoteType === REMOTE_MINIO) { + return ConnectionStringParser.serialize({ type: "s3", settings }); + } + if (settings.remoteType === REMOTE_P2P) { + return ConnectionStringParser.serialize({ type: "p2p", settings }); + } + return ConnectionStringParser.serialize({ type: "couchdb", settings }); +} + export function paneRemoteConfig( this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel, addPane }: PageFunctions ): void { - const remoteNameMap = { - [REMOTE_COUCHDB]: $msg("obsidianLiveSyncSettingTab.optionCouchDB"), - [REMOTE_MINIO]: $msg("obsidianLiveSyncSettingTab.optionMinioS3R2"), - [REMOTE_P2P]: "Only Peer-to-Peer", - } as const; - { /* E2EE */ const E2EEInitialProps = { @@ -91,24 +113,268 @@ export function paneRemoteConfig( }); } { + // TODO: very WIP. need to refactor the UI. void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleRemoteServer"), () => {}).then((paneEl) => { - const setting = new Setting(paneEl).setName($msg("Active Remote Configuration")); + const actions = new Setting(paneEl).setName("Remote Databases"); + // actions.addButton((button) => + // button + // .setButtonText("Change Remote and Setup") + // .setCta() + // .onClick(async () => { + // const setupManager = this.core.getModule(SetupManager); + // const originalSettings = getSettingsFromEditingSettings(this.editingSettings); + // await setupManager.onSelectServer(originalSettings, UserMode.Update); + // }) + // ); - const el = setting.controlEl.createDiv({}); - el.setText(`${remoteNameMap[this.editingSettings.remoteType] || " - "}`); - setting.addButton((button) => - button - .setButtonText("Change Remote and Setup") - .setCta() - .onClick(async () => { - const setupManager = this.core.getModule(SetupManager); - const originalSettings = getSettingsFromEditingSettings(this.editingSettings); - await setupManager.onSelectServer(originalSettings, UserMode.Update); - }) + // Connection List + const listContainer = paneEl.createDiv({ cls: "sls-remote-list" }); + const syncRemoteConfigurationBuffers = () => { + const currentConfigs = cloneRemoteConfigurations(this.core.settings.remoteConfigurations); + this.editingSettings.remoteConfigurations = currentConfigs; + this.editingSettings.activeConfigurationId = this.core.settings.activeConfigurationId; + if (this.initialSettings) { + this.initialSettings.remoteConfigurations = cloneRemoteConfigurations(currentConfigs); + this.initialSettings.activeConfigurationId = this.core.settings.activeConfigurationId; + } + }; + const persistRemoteConfigurations = async (synchroniseActiveRemote: boolean = false) => { + await this.services.setting.updateSettings((currentSettings) => { + currentSettings.remoteConfigurations = cloneRemoteConfigurations( + this.editingSettings.remoteConfigurations + ); + currentSettings.activeConfigurationId = this.editingSettings.activeConfigurationId; + if (synchroniseActiveRemote && currentSettings.activeConfigurationId) { + const activated = activateRemoteConfiguration( + currentSettings, + currentSettings.activeConfigurationId + ); + if (activated) { + return activated; + } + } + return currentSettings; + }, true); + + if (synchroniseActiveRemote) { + await this.saveAllDirtySettings(); + } + + syncRemoteConfigurationBuffers(); + this.requestUpdate(); + }; + const runRemoteSetup = async ( + baseSettings: ObsidianLiveSyncSettings, + remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P + ): Promise => { + const setupManager = this.core.getModule(SetupManager); + const dialogManager = setupManager.dialogManager; + let targetRemoteType = remoteType; + + if (targetRemoteType === undefined) { + const method = await dialogManager.openWithExplicitCancel(SetupRemote); + if (method === "cancelled") { + return false; + } + targetRemoteType = + method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB; + } + + if (targetRemoteType === REMOTE_MINIO) { + const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings); + if (bucketConf === "cancelled" || typeof bucketConf !== "object") { + return false; + } + return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO }; + } + + if (targetRemoteType === REMOTE_P2P) { + const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings); + if (p2pConf === "cancelled" || typeof p2pConf !== "object") { + return false; + } + return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P }; + } + + const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings); + if (couchConf === "cancelled" || typeof couchConf !== "object") { + return false; + } + return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB }; + }; + const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({ + ...DEFAULT_SETTINGS, + ...getSettingsFromEditingSettings(this.editingSettings), + }); + const createNewRemoteSettings = (): ObsidianLiveSyncSettings => ({ + ...DEFAULT_SETTINGS, + encrypt: this.editingSettings.encrypt, + usePathObfuscation: this.editingSettings.usePathObfuscation, + passphrase: this.editingSettings.passphrase, + configPassphraseStore: this.editingSettings.configPassphraseStore, + }); + const addRemoteConfiguration = async () => { + const name = await this.services.UI.confirm.askString("Remote name", "Display name", "New Remote"); + if (name === false) { + return; + } + const nextSettings = await runRemoteSetup(createNewRemoteSettings()); + if (!nextSettings) { + return; + } + const id = createRemoteConfigurationId(); + const configs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations); + configs[id] = { + id, + name: name.trim() || "New Remote", + uri: serializeRemoteConfiguration(nextSettings), + isEncrypted: nextSettings.encrypt, + }; + 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 () => { + await addRemoteConfiguration(); + }) ); + const refreshList = () => { + listContainer.empty(); + const configs = this.editingSettings.remoteConfigurations || {}; + for (const config of Object.values(configs)) { + const row = new Setting(listContainer) + .setName(config.name) + .setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy + + if (config.id === this.editingSettings.activeConfigurationId) { + row.nameEl.addClass("sls-active-remote-name"); + row.nameEl.appendText(" (Active)"); + } + + row.addButton((btn) => + btn.setButtonText("Configure").onClick(async () => { + const parsed = ConnectionStringParser.parse(config.uri); + const workSettings = createBaseRemoteSettings(); + if (parsed.type === "couchdb") { + workSettings.remoteType = REMOTE_COUCHDB; + } else if (parsed.type === "s3") { + workSettings.remoteType = REMOTE_MINIO; + } else { + workSettings.remoteType = REMOTE_P2P; + } + Object.assign(workSettings, parsed.settings); + + const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType); + if (!nextSettings) { + return; + } + + const nextConfigs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations); + nextConfigs[config.id] = { + ...config, + uri: serializeRemoteConfiguration(nextSettings), + isEncrypted: nextSettings.encrypt, + }; + this.editingSettings.remoteConfigurations = nextConfigs; + await persistRemoteConfigurations(config.id === this.editingSettings.activeConfigurationId); + 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") + .setDisabled(config.id === this.editingSettings.activeConfigurationId) + .onClick(async () => { + this.editingSettings.activeConfigurationId = config.id; + await persistRemoteConfigurations(true); + refreshList(); + }) + ); + } + }; + refreshList(); }); } - { + // eslint-disable-next-line no-constant-condition + if (false) { const initialProps = { info: getCouchDBConfigSummary(this.editingSettings), }; @@ -143,7 +409,8 @@ export function paneRemoteConfig( ); }); } - { + // eslint-disable-next-line no-constant-condition + if (false) { const initialProps = { info: getBucketConfigSummary(this.editingSettings), }; @@ -178,7 +445,8 @@ export function paneRemoteConfig( ); }); } - { + // eslint-disable-next-line no-constant-condition + if (false) { const getDevicePeerId = () => this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || ""; const initialProps = { info: getP2PConfigSummary(this.editingSettings, { diff --git a/updates.md b/updates.md index 48d45d4..df9579f 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,23 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +## Unreleased 2 + +3rd April, 2026 + +As this commit is a bit of a fragile matter, I shall add a note here. + +You know that untagged updates shall not be tested well. please be careful to use your own build. In most cases, I check that the warnings have disappeared, that the code compiles successfully without any warnings, and that it runs on the desktop. + +### Fixed + +- No unexpected error (about a replicator) during early stage of initialisation. + +### New features + +- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes. +- We can switch between multiple Remote Databases in the settings dialogue. + ## Unreleased 2nd April, 2026