mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-04-05 16:45:20 +00:00
Compare commits
4 Commits
main
...
0.25.56+pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc22d61a3a | ||
|
|
d709bcc1d0 | ||
|
|
d7088be8af | ||
|
|
f17f1ecd93 |
206
docs/design_docs_of_remote_configurations.md
Normal file
206
docs/design_docs_of_remote_configurations.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# The design document of remote configuration management
|
||||
|
||||
## Goal
|
||||
|
||||
- Allow us to manage multiple remote connections in a single vault.
|
||||
- Keep the existing synchronisation implementations working without requiring a large rewrite.
|
||||
- Provide a safe migration path from the previous single-remote configuration model.
|
||||
- Allow connections to be imported and exported in a compact and reusable format.
|
||||
|
||||
## Motivation
|
||||
|
||||
Historically, Self-hosted LiveSync stored one effective remote configuration directly in the main settings. This was simple, but it had several limitations.
|
||||
|
||||
- We could only keep one CouchDB, one bucket, or one Peer-to-Peer target as the effective configuration at a time.
|
||||
- Switching between same-type-remotes required manually rewriting the active settings.
|
||||
- Setup URI, QR code, CLI setup, and similar entry points all restored settings differently, which made migration logic easy to miss.
|
||||
- The internal settings shape had gradually become a mix of user-facing settings, transport-specific credentials, and compatibility-oriented values.
|
||||
|
||||
Once multiple remotes of the same type became desirable, the previous model no longer scaled well enough. We therefore needed a structure that could store many remotes, still expose one effective remote to the replication logic, and keep migration and import behaviour consistent.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- Existing synchronisation features must continue to read an effective remote configuration from the current settings.
|
||||
- Existing vaults must continue to work without requiring manual reconfiguration.
|
||||
- Setup URI, QR code, CLI setup, protocol handlers, and other imported settings must be normalised in the same way.
|
||||
- Import and export must be compact enough to be shared easily.
|
||||
- We must be explicit that exported connection strings may contain credentials or secrets.
|
||||
|
||||
## Outlined methods and implementation plans
|
||||
|
||||
### Abstract
|
||||
|
||||
The current settings now have two layers for remote configuration.
|
||||
|
||||
1. A stored collection of named remotes.
|
||||
2. One active remote projected into the legacy flat settings fields.
|
||||
|
||||
This means the replication and database layers can continue to read the effective remote from the existing settings fields, while the settings dialogue and migration logic can manage many stored remotes.
|
||||
|
||||
In short, the list is the source of truth for saved remotes, and the legacy fields remain the runtime compatibility layer.
|
||||
|
||||
### Data model
|
||||
|
||||
The main settings now contain the following properties.
|
||||
|
||||
```typescript
|
||||
type RemoteConfiguration = {
|
||||
id: string;
|
||||
name: string;
|
||||
uri: string;
|
||||
isEncrypted: boolean;
|
||||
};
|
||||
|
||||
type RemoteConfigurations = {
|
||||
remoteConfigurations: Record<string, RemoteConfiguration>;
|
||||
activeConfigurationId: string;
|
||||
};
|
||||
```
|
||||
|
||||
Each entry stores a connection string in `uri`.
|
||||
|
||||
- `sls+http://` or `sls+https://` for CouchDB-compatible remotes
|
||||
- `sls+s3://` for bucket-style remotes
|
||||
- `sls+p2p://` for Peer-to-Peer remotes
|
||||
|
||||
This structure allows multiple remotes of the same type to be stored without adding a large number of duplicated settings fields.
|
||||
|
||||
### Runtime compatibility
|
||||
|
||||
The replication logic still reads the effective remote from legacy flat settings such as the following.
|
||||
|
||||
- `remoteType`
|
||||
- `couchDB_URI`, `couchDB_USER`, `couchDB_PASSWORD`, `couchDB_DBNAME`
|
||||
- `endpoint`, `bucket`, `accessKey`, `secretKey`, and related bucket fields
|
||||
- `P2P_roomID`, `P2P_passphrase`, and related Peer-to-Peer fields
|
||||
|
||||
When a remote is activated, its connection string is parsed and projected into these legacy fields. Therefore, existing services do not need to know whether the remote came from an old vault, a Setup URI, or the new remote list.
|
||||
|
||||
This projection is intentionally one-way at runtime. The stored remote list is the persistent catalogue, while the flat fields describe the remote currently in use.
|
||||
|
||||
### Connection string format
|
||||
|
||||
The connection string is the transport-neutral storage format for a remote entry.
|
||||
|
||||
Benefits:
|
||||
|
||||
- It is compact enough for clipboard-based workflows.
|
||||
- It can be used for import and export in the settings dialogue.
|
||||
- It avoids introducing a separate serialisation format only for the remote list.
|
||||
- It can be parsed into the legacy settings shape whenever the active remote changes.
|
||||
|
||||
This is not equivalent to Setup URI.
|
||||
|
||||
- Setup URI represents a broader settings transfer workflow.
|
||||
- A remote connection string represents one remote only.
|
||||
|
||||
### Import and export
|
||||
|
||||
The settings dialogue now supports the following workflows.
|
||||
|
||||
- Add connection: create a new remote by using the remote setup dialogues.
|
||||
- Import connection: paste a connection string, validate it, and save it as a named remote.
|
||||
- Export: copy a stored remote connection string to the clipboard.
|
||||
|
||||
Import normalises the string by parsing and serialising it again before saving. This ensures that equivalent but differently formatted URIs are saved in a canonical form.
|
||||
|
||||
Export is intentionally simple. It copies the connection string itself, because this is the most direct representation of one remote entry.
|
||||
|
||||
### Security note
|
||||
|
||||
Connection strings may include credentials, secrets, JWT-related values, or Peer-to-Peer passphrases.
|
||||
|
||||
Therefore:
|
||||
|
||||
- Export is a deliberate clipboard operation.
|
||||
- Import trusts the supplied connection string as-is after parsing.
|
||||
- We should regard exported connection strings as sensitive information, much like Setup URI or a credentials-bearing configuration file.
|
||||
|
||||
The `isEncrypted` field is currently reserved for future expansion. At present, the connection string itself is stored plainly inside the settings data, in the same sense that the effective runtime configuration can contain usable remote credentials.
|
||||
|
||||
### Migration strategy
|
||||
|
||||
Older vaults store only one effective remote in the flat settings fields. The migration creates a first remote list from those values.
|
||||
|
||||
Rules:
|
||||
|
||||
- If no remote list exists and the legacy fields contain a CouchDB configuration, create `legacy-couchdb`.
|
||||
- If no remote list exists and the legacy fields contain a bucket configuration, create `legacy-s3`.
|
||||
- If no remote list exists and the legacy fields contain a Peer-to-Peer configuration, create `legacy-p2p`.
|
||||
- If more than one legacy remote is populated, create all possible entries and select the active one according to `remoteType`.
|
||||
|
||||
This migration is intentionally additive. It does not remove the flat fields because they remain necessary as the active runtime projection.
|
||||
|
||||
### Normalisation and application paths
|
||||
|
||||
One important design lesson from this work is that migration cannot rely only on loading `data.json`.
|
||||
|
||||
Settings may enter the system from several routes:
|
||||
|
||||
- normal settings load
|
||||
- Setup URI
|
||||
- QR code
|
||||
- protocol handler
|
||||
- CLI setup
|
||||
- Peer-to-Peer remote configuration retrieval
|
||||
- red flag based remote adjustment
|
||||
- settings markdown import
|
||||
|
||||
To keep behaviour consistent, normalisation is centralised in the settings service.
|
||||
|
||||
- `adjustSettings` is responsible for in-place normalisation and migration of a settings object.
|
||||
- `applyExternalSettings` is responsible for applying imported or externally supplied settings after passing them through the same normalisation flow.
|
||||
|
||||
This ensures that imported settings can migrate to the current remote list model even if they never passed through the ordinary `loadSettings` path.
|
||||
|
||||
### Why not store only the remote list
|
||||
|
||||
It would be possible to let all consumers parse the active remote every time and stop using the flat fields entirely. However, this would require broader changes across replication, diagnostics, and compatibility layers.
|
||||
|
||||
The current design keeps the change set limited.
|
||||
|
||||
- The remote list improves storage and UX.
|
||||
- The flat fields preserve compatibility and reduce migration risk.
|
||||
|
||||
This is a pragmatic transitional architecture, not an accidental duplication.
|
||||
|
||||
## Test strategy
|
||||
|
||||
The feature should be tested from four viewpoints.
|
||||
|
||||
1. Migration from old settings.
|
||||
- A vault with only legacy flat remote settings should gain a remote list automatically.
|
||||
- The correct active remote should be selected according to `remoteType`.
|
||||
|
||||
2. Runtime activation.
|
||||
- Activating a stored remote should correctly project its values into the effective flat settings.
|
||||
|
||||
3. External import paths.
|
||||
- Setup URI, QR code, CLI setup, Peer-to-Peer remote config, red flag adjustment, and settings markdown import should all pass through the same normalisation path.
|
||||
|
||||
4. Import and export.
|
||||
- Imported connection strings should be parsed, canonicalised, named, and stored correctly.
|
||||
- Export should copy the exact saved connection string.
|
||||
|
||||
## Documentation strategy
|
||||
|
||||
- This document explains the design and compatibility model of remote configuration management.
|
||||
- User-facing setup documents should explain only how to add, import, export, and activate remotes.
|
||||
- Release notes may refer to this document when changes in remote handling are significant.
|
||||
|
||||
## Outlook
|
||||
|
||||
Import/export configuration strings should also be encrypted in the future, but this is a separate feature that can be added on top of the current design.
|
||||
|
||||
## Consideration and conclusion
|
||||
|
||||
The remote configuration list solves the practical need to manage multiple remotes without forcing the whole codebase to abandon the previous effective-settings model at once.
|
||||
|
||||
Its core idea is modest but effective.
|
||||
|
||||
- Store named remotes as connection strings.
|
||||
- Select one active remote.
|
||||
- Project it into the legacy settings for runtime use.
|
||||
- Normalise every imported settings object through the same path.
|
||||
|
||||
This keeps the implementation understandable and migration-friendly. It also opens the door for future work, such as encrypted per-remote storage, richer remote metadata, or remote-scoped options, without forcing another large redesign of how remotes are represented.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
} as ObsidianLiveSyncSettings;
|
||||
|
||||
console.log(`[Command] setup -> ${settingsPath}`);
|
||||
await core.services.setting.applyPartial(nextSettings, true);
|
||||
await core.services.setting.applyExternalSettings(nextSettings, true);
|
||||
await core.services.control.applySettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ function createCoreMock() {
|
||||
applySettings: vi.fn(async () => {}),
|
||||
},
|
||||
setting: {
|
||||
applyExternalSettings: vi.fn(async () => {}),
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
@@ -176,9 +177,9 @@ describe("runCommand abnormal cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.setting.applyExternalSettings).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
const [appliedSettings, saveImmediately] = core.services.setting.applyPartial.mock.calls[0];
|
||||
const [appliedSettings, saveImmediately] = core.services.setting.applyExternalSettings.mock.calls[0];
|
||||
expect(saveImmediately).toBe(true);
|
||||
expect(appliedSettings.couchDB_URI).toBe("http://127.0.0.1:5984");
|
||||
expect(appliedSettings.couchDB_DBNAME).toBe("livesync-test-db");
|
||||
@@ -198,7 +199,7 @@ describe("runCommand abnormal cases", () => {
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(core.services.setting.applyPartial).not.toHaveBeenCalled();
|
||||
expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"cli": "node dist/index.cjs",
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
"test:e2e:push-pull": "bash test/test-push-pull-linux.sh",
|
||||
"test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh",
|
||||
"test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh",
|
||||
"test:e2e:p2p": "bash test/test-p2p-three-nodes-conflict-linux.sh",
|
||||
"test:e2e:p2p-upload-download-repro": "bash test/test-p2p-upload-download-repro-linux.sh",
|
||||
"test:e2e:p2p-host": "bash test/test-p2p-host-linux.sh",
|
||||
"test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh",
|
||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||
"pretest:e2e:all": "npm run build",
|
||||
"test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p",
|
||||
"pretest:e2e:docker:all": "npm run build:docker",
|
||||
"test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh",
|
||||
"test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh",
|
||||
"test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh",
|
||||
"test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh",
|
||||
"test:e2e:docker:p2p": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-three-nodes-conflict-linux.sh",
|
||||
"test:e2e:docker:p2p-sync": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-sync-linux.sh",
|
||||
"test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"cli": "node dist/index.cjs",
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
"test:e2e:push-pull": "bash test/test-push-pull-linux.sh",
|
||||
"test:e2e:setup-put-cat": "bash test/test-setup-put-cat-linux.sh",
|
||||
"test:e2e:sync-two-local": "bash test/test-sync-two-local-databases-linux.sh",
|
||||
"test:e2e:p2p": "bash test/test-p2p-three-nodes-conflict-linux.sh",
|
||||
"test:e2e:p2p-upload-download-repro": "bash test/test-p2p-upload-download-repro-linux.sh",
|
||||
"test:e2e:p2p-host": "bash test/test-p2p-host-linux.sh",
|
||||
"test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh",
|
||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||
"pretest:e2e:all": "npm run build",
|
||||
"test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p",
|
||||
"pretest:e2e:docker:all": "npm run build:docker",
|
||||
"test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh",
|
||||
"test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh",
|
||||
"test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh",
|
||||
"test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh",
|
||||
"test:e2e:docker:p2p": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-three-nodes-conflict-linux.sh",
|
||||
"test:e2e:docker:p2p-sync": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-sync-linux.sh",
|
||||
"test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"name": "livesync-cli-runtime",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"werift": "^0.22.9",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-adapter-memory": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"pouchdb-wrappers": "*",
|
||||
"transform-pouch": "^2.0.0"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "livesync-cli-runtime",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"werift": "^0.22.9",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-adapter-memory": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"pouchdb-wrappers": "*",
|
||||
"transform-pouch": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -276,7 +276,7 @@ export class P2PReplicatorShim implements P2PReplicatorBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.services.setting.applyPartial(remoteConfig, true);
|
||||
await this.services.setting.applyExternalSettings(remoteConfig, true);
|
||||
if (yn !== DROP) {
|
||||
await this.plugin.core.services.appLifecycle.scheduleRestart();
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ And you can also drop the local database to rebuild from the remote device.`,
|
||||
|
||||
// this.plugin.settings = remoteConfig;
|
||||
// await this.plugin.saveSettings();
|
||||
await this.core.services.setting.applyPartial(remoteConfig);
|
||||
await this.core.services.setting.applyExternalSettings(remoteConfig);
|
||||
if (yn === DROP) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
} else {
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: c6229cd14a...37b8e2813e
@@ -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);
|
||||
|
||||
@@ -277,27 +277,36 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
async updateMessageArea() {
|
||||
if (this.messageArea) {
|
||||
const messageLines = [];
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus);
|
||||
const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e);
|
||||
const stringMessages = messages.filter((m): m is string => typeof m === "string"); // for 'startsWith'
|
||||
const networkMessages = stringMessages.filter((m) => m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
const otherMessages = stringMessages.filter((m) => !m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
if (!this.messageArea) return;
|
||||
|
||||
messageLines.push(...otherMessages);
|
||||
|
||||
if (
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.ICON &&
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.HIDDEN
|
||||
) {
|
||||
messageLines.push(...networkMessages);
|
||||
} else if (this.settings.networkWarningStyle === NetworkWarningStyles.ICON) {
|
||||
if (networkMessages.length > 0) messageLines.push("🔗❌");
|
||||
}
|
||||
this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n");
|
||||
const showStatusOnEditor = this.settings?.showStatusOnEditor ?? false;
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.style.display = showStatusOnEditor ? "" : "none";
|
||||
}
|
||||
if (!showStatusOnEditor) {
|
||||
this.messageArea.innerText = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const messageLines = [];
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus);
|
||||
const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e);
|
||||
const stringMessages = messages.filter((m): m is string => typeof m === "string"); // for 'startsWith'
|
||||
const networkMessages = stringMessages.filter((m) => m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
const otherMessages = stringMessages.filter((m) => !m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
|
||||
messageLines.push(...otherMessages);
|
||||
|
||||
if (
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.ICON &&
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.HIDDEN
|
||||
) {
|
||||
messageLines.push(...networkMessages);
|
||||
} else if (this.settings.networkWarningStyle === NetworkWarningStyles.ICON) {
|
||||
if (networkMessages.length > 0) messageLines.push("🔗❌");
|
||||
}
|
||||
this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n");
|
||||
}
|
||||
|
||||
onActiveLeafChange() {
|
||||
@@ -326,6 +335,9 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
this.statusBar?.setText(newMsg.split("\n")[0]);
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.style.display = this.settings?.showStatusOnEditor ? "" : "none";
|
||||
}
|
||||
if (this.settings?.showStatusOnEditor && this.statusDiv) {
|
||||
if (this.settings.showLongerLogInsideEditor) {
|
||||
const now = new Date().getTime();
|
||||
@@ -402,6 +414,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
this.messageArea = this.statusDiv.createDiv({ cls: "livesync-status-messagearea" });
|
||||
this.logMessage = this.statusDiv.createDiv({ cls: "livesync-status-logmessage" });
|
||||
this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" });
|
||||
this.statusDiv.style.display = this.settings?.showStatusOnEditor ? "" : "none";
|
||||
eventHub.onEvent(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition());
|
||||
if (this.settings?.showStatusOnStatusbar) {
|
||||
this.statusBar = this.services.API.addStatusBarItem();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,9 @@ export function paneGeneral(
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.display());
|
||||
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)),
|
||||
});
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
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";
|
||||
@@ -21,6 +24,14 @@ 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 { 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";
|
||||
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 +50,54 @@ 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<string, RemoteConfiguration> | undefined
|
||||
): Record<string, RemoteConfiguration> {
|
||||
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 });
|
||||
}
|
||||
|
||||
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,
|
||||
{ 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 +139,335 @@ 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<ObsidianLiveSyncSettings | false> => {
|
||||
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();
|
||||
};
|
||||
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) =>
|
||||
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 || {};
|
||||
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) =>
|
||||
setEmojiButton(btn, "🔧", "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("✅")
|
||||
.setTooltip("Activate", { delay: 10, placement: "top" })
|
||||
.setDisabled(config.id === this.editingSettings.activeConfigurationId)
|
||||
.onClick(async () => {
|
||||
this.editingSettings.activeConfigurationId = config.id;
|
||||
await persistRemoteConfigurations(true);
|
||||
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();
|
||||
});
|
||||
}
|
||||
{
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const initialProps = {
|
||||
info: getCouchDBConfigSummary(this.editingSettings),
|
||||
};
|
||||
@@ -143,7 +502,8 @@ export function paneRemoteConfig(
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const initialProps = {
|
||||
info: getBucketConfigSummary(this.editingSettings),
|
||||
};
|
||||
@@ -178,7 +538,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, {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,7 @@ export async function adjustSettingToRemote(
|
||||
...config,
|
||||
...Object.fromEntries(differentItems),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
await host.services.setting.applyPartial(config, true);
|
||||
await host.services.setting.applyExternalSettings(config, true);
|
||||
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
const updatedConfig = host.services.setting.currentSettings();
|
||||
|
||||
@@ -49,6 +49,10 @@ const createSettingServiceMock = () => {
|
||||
return {
|
||||
settings,
|
||||
currentSettings: vi.fn(() => settings),
|
||||
applyExternalSettings: vi.fn((partial: any, _feedback?: boolean) => {
|
||||
Object.assign(settings, partial);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
applyPartial: vi.fn((partial: any, _feedback?: boolean) => {
|
||||
Object.assign(settings, partial);
|
||||
return Promise.resolve();
|
||||
@@ -552,7 +556,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
const mismatchAcceptedKeys = Object.keys(TweakValuesRecommendedTemplate).filter(
|
||||
@@ -579,7 +583,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled();
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
17
updates.md
17
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
|
||||
|
||||
Reference in New Issue
Block a user