mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-05 11:15:45 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3e2c983f | ||
|
|
185ec2c392 | ||
|
|
b1d7f3b622 | ||
|
|
c27dbbd572 | ||
|
|
83087f159e | ||
|
|
d12d0f8ef7 | ||
|
|
e9b2477bf3 | ||
|
|
9c59cd15d1 | ||
|
|
d672ff04c3 | ||
|
|
cd5f5aa740 | ||
|
|
34d8fe5d31 | ||
|
|
d15df7367f | ||
|
|
36b2ea7096 |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
Generated
+48
-48
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.3",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -1887,9 +1887,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1968,9 +1968,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4775,9 +4775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz",
|
||||
"integrity": "sha512-rdzEubzLStioanE67pps2XSGZ2UGyMIyeEoKsdPztQLLW8wM05PWApOPbJujIydHcDr2hFanbDFe89Lk7Mn4XQ==",
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.3.tgz",
|
||||
"integrity": "sha512-M6+mOlf4R60pf6mMuQdIlOoeIjOlDQn/hIRjELCkAwAcJXo5V/l8LfBCujeuvVk9FOzO3S0Q4WET9VaDHDxosw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -4896,9 +4896,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@wdio/config/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5264,9 +5264,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5734,9 +5734,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5986,9 +5986,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -7673,9 +7673,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7770,9 +7770,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7836,9 +7836,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8013,9 +8013,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8215,9 +8215,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9052,9 +9052,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/globby/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12668,9 +12668,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.2-cli",
|
||||
"version": "1.0.4-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.2-webapp",
|
||||
"version": "1.0.4-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.2-webpeer",
|
||||
"version": "1.0.4-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -177,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.3",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -15,13 +15,13 @@ import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/co
|
||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { useJournalSyncFeature } from "@vrtmrz/livesync-commonlib/journal-sync";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -139,6 +139,7 @@ export class LiveSyncBaseCore<
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
@@ -278,8 +279,6 @@ export class LiveSyncBaseCore<
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
// Journal providers are selected through the composed remote-provider registry.
|
||||
useJournalSyncFeature(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `--vault <path>` / `-V <path>`: (daemon/mirror only) Path to the vault directory containing `.md` files.
|
||||
- Allows the PouchDB database directory and the actual vault directory to be different locations.
|
||||
- For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`.
|
||||
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
|
||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||
- All remaining commands leave the settings file unchanged by default.
|
||||
- Temporary values used to suspend synchronisation or select a remote for one command are never written.
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -333,6 +337,7 @@ Options:
|
||||
--debug, -d Enable debug logging (includes verbose)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
|
||||
--write-settings Write setting changes after a successful command
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface CLIOptions {
|
||||
verbose?: boolean;
|
||||
debug?: boolean;
|
||||
force?: boolean;
|
||||
writeSettings?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
|
||||
+116
-29
@@ -25,10 +25,20 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature";
|
||||
import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult";
|
||||
import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
|
||||
import { createDefaultCliSettings } from "./cliSettingsDefaults";
|
||||
import {
|
||||
applyStoredSetting,
|
||||
changedSettingKeys,
|
||||
CLI_RUNTIME_ONLY_SETTING_KEYS,
|
||||
cloneSettings,
|
||||
isSettingsWriteCommand,
|
||||
preserveStoredSetting,
|
||||
reconcileDurableSettings,
|
||||
settingsEqual,
|
||||
} from "./settingsPersistence";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -92,6 +102,7 @@ Options:
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to the vault directory containing .md files
|
||||
(defaults to database-path; allows separate PouchDB and vault dirs)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--write-settings Write setting changes after a successful command
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
@@ -141,6 +152,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
let verbose = false;
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let writeSettings = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
@@ -197,6 +209,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
case "-f":
|
||||
force = true;
|
||||
break;
|
||||
case "--write-settings":
|
||||
writeSettings = true;
|
||||
break;
|
||||
default: {
|
||||
if (!databasePath) {
|
||||
if (command === "daemon" && isCLICommand(token)) {
|
||||
@@ -237,6 +252,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
verbose,
|
||||
debug,
|
||||
force,
|
||||
writeSettings,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
@@ -411,16 +427,26 @@ export async function main(
|
||||
|
||||
// Setup settings handlers
|
||||
const settingService = serviceHubInstance.setting;
|
||||
const originalSettingsText = await fs.readFile(settingsPath, "utf-8").catch(() => undefined);
|
||||
let latestPreparedSettingsText: string | undefined;
|
||||
let preparedSettingsRevision = 0;
|
||||
let commandIsRunning = false;
|
||||
let commandPreparedSettingsTexts: string[] = [];
|
||||
|
||||
(settingService as InjectableSettingService<NodeServiceContext>).saveData.setHandler(
|
||||
async (data: ObsidianLiveSyncSettings) => {
|
||||
try {
|
||||
await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8");
|
||||
latestPreparedSettingsText = JSON.stringify(data, null, 2);
|
||||
preparedSettingsRevision++;
|
||||
if (commandIsRunning) {
|
||||
commandPreparedSettingsTexts.push(latestPreparedSettingsText);
|
||||
}
|
||||
if (options.verbose) {
|
||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||
writeStderrLine(standardIo, `[Settings] Prepared an update for ${settingsPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, `[Settings] Failed to save:`, error);
|
||||
writeStderrLine(standardIo, `[Settings] Failed to prepare an update:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -503,24 +529,21 @@ export async function main(
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||
|
||||
// Restore settings file on any exit to undo lifecycle mutations.
|
||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||
process.on("exit", () => {
|
||||
if (settingsBackup) {
|
||||
const tmpPath = settingsPath + ".tmp";
|
||||
try {
|
||||
fsSync.writeFileSync(tmpPath, settingsBackup, "utf-8");
|
||||
fsSync.renameSync(tmpPath, settingsPath);
|
||||
} catch (err) {
|
||||
writeStderrLine(standardIo, "[Settings] Failed to restore settings on exit:", err);
|
||||
const writeSettingsAtomically = async (content: string | undefined): Promise<void> => {
|
||||
if (content === undefined || content === originalSettingsText) return;
|
||||
const temporaryPath = `${settingsPath}.${process.pid}.tmp`;
|
||||
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, content, "utf-8");
|
||||
await fs.rename(temporaryPath, settingsPath);
|
||||
if (options.verbose) {
|
||||
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
await fs.unlink(temporaryPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start the core
|
||||
try {
|
||||
@@ -531,9 +554,15 @@ export async function main(
|
||||
writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
|
||||
process.exit(1);
|
||||
}
|
||||
const settingsAfterLoadText = latestPreparedSettingsText
|
||||
? preserveStoredSetting(latestPreparedSettingsText, originalSettingsText, "useIndexedDBAdapter")
|
||||
: originalSettingsText;
|
||||
|
||||
// Capture sync settings before suspendAllSync() clobbers them.
|
||||
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
||||
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
||||
const settingsBeforeSuspend = cloneSettings(core.services.setting.currentSettings());
|
||||
const durableSettingsBeforeSuspend = cloneSettings(settingsBeforeSuspend);
|
||||
applyStoredSetting(durableSettingsBeforeSuspend, settingsAfterLoadText, "useIndexedDBAdapter");
|
||||
const originalSyncSettings = {
|
||||
liveSync: settingsBeforeSuspend.liveSync,
|
||||
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
||||
@@ -544,7 +573,19 @@ export async function main(
|
||||
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
||||
};
|
||||
await core.services.setting.suspendAllSync();
|
||||
const settingsAfterSuspend = cloneSettings(core.services.setting.currentSettings());
|
||||
await core.services.control.onReady();
|
||||
const settingsBeforeCommand = cloneSettings(core.services.setting.currentSettings());
|
||||
const transientSettingKeys = changedSettingKeys(settingsBeforeSuspend, settingsAfterSuspend);
|
||||
for (const key of CLI_RUNTIME_ONLY_SETTING_KEYS) {
|
||||
transientSettingKeys.add(key);
|
||||
}
|
||||
const durableSettingsBeforeCommand = reconcileDurableSettings({
|
||||
durableBase: durableSettingsBeforeSuspend,
|
||||
runtimeBaseline: settingsAfterSuspend,
|
||||
runtimeCurrent: settingsBeforeCommand,
|
||||
preserveKeys: transientSettingKeys,
|
||||
});
|
||||
|
||||
infoLog(`[Ready] LiveSync is running`);
|
||||
infoLog(`[Ready] Press Ctrl+C to stop`);
|
||||
@@ -568,14 +609,58 @@ export async function main(
|
||||
infoLog("");
|
||||
}
|
||||
|
||||
const result = await commandRunner(options, {
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
});
|
||||
commandPreparedSettingsTexts = [];
|
||||
let result: boolean;
|
||||
try {
|
||||
commandIsRunning = true;
|
||||
result = await commandRunner(options, {
|
||||
databasePath,
|
||||
vaultPath,
|
||||
core,
|
||||
p2pReplicator,
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
});
|
||||
} finally {
|
||||
commandIsRunning = false;
|
||||
}
|
||||
|
||||
let settingsTextToCommit: string | undefined;
|
||||
if (result && options.command === "setup") {
|
||||
settingsTextToCommit = commandPreparedSettingsTexts[0];
|
||||
if (settingsTextToCommit === undefined) {
|
||||
throw new Error("The setup command completed without preparing its settings update.");
|
||||
}
|
||||
} else if (result && (isSettingsWriteCommand(options.command) || options.writeSettings)) {
|
||||
const runtimeSettingsAfterCommand = cloneSettings(core.services.setting.currentSettings());
|
||||
const durableSettingsAfterCommand = reconcileDurableSettings({
|
||||
durableBase: durableSettingsBeforeCommand,
|
||||
runtimeBaseline: settingsBeforeCommand,
|
||||
runtimeCurrent: runtimeSettingsAfterCommand,
|
||||
preserveKeys: transientSettingKeys,
|
||||
command: options.command,
|
||||
});
|
||||
|
||||
if (
|
||||
isSettingsWriteCommand(options.command) ||
|
||||
!settingsEqual(durableSettingsAfterCommand, durableSettingsBeforeSuspend)
|
||||
) {
|
||||
const runtimeSettings = cloneSettings(core.services.setting.currentSettings());
|
||||
const revisionBeforeSave = preparedSettingsRevision;
|
||||
try {
|
||||
await core.services.setting.updateSettings(() => cloneSettings(durableSettingsAfterCommand), true);
|
||||
if (preparedSettingsRevision === revisionBeforeSave || latestPreparedSettingsText === undefined) {
|
||||
throw new Error("The setting service did not prepare the requested settings update.");
|
||||
}
|
||||
settingsTextToCommit = latestPreparedSettingsText;
|
||||
} finally {
|
||||
await core.services.setting.updateSettings(() => runtimeSettings, false);
|
||||
}
|
||||
} else {
|
||||
settingsTextToCommit = settingsAfterLoadText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
||||
process.exitCode = 1;
|
||||
@@ -584,10 +669,12 @@ export async function main(
|
||||
}
|
||||
|
||||
if (options.command === "daemon" && result) {
|
||||
await writeSettingsAtomically(settingsTextToCommit);
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
await core.services.control.onUnload();
|
||||
await writeSettingsAtomically(settingsTextToCommit);
|
||||
}
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, `[Error] Failed to start:`, error);
|
||||
|
||||
@@ -206,4 +206,13 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.interval).toBe(30);
|
||||
});
|
||||
|
||||
it("parses --write-settings as a global option", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--write-settings", "ls"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.command).toBe("ls");
|
||||
expect(parsed.writeSettings).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.2-cli",
|
||||
"version": "1.0.4-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.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:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/settingsPersistence.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",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { CLICommand } from "./commands/types";
|
||||
|
||||
const SETTINGS_WRITE_COMMANDS = new Set<CLICommand>([
|
||||
"setup",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
]);
|
||||
|
||||
const REMOTE_SETTINGS_WRITE_COMMANDS = new Set<CLICommand>([
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
]);
|
||||
|
||||
export const CLI_RUNTIME_ONLY_SETTING_KEYS = new Set<keyof ObsidianLiveSyncSettings>([
|
||||
"disableCheckingConfigMismatch",
|
||||
"suspendFileWatching",
|
||||
"suspendParseReplicationResult",
|
||||
]);
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (value === undefined) return value;
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function cloneSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
return cloneJsonValue(settings);
|
||||
}
|
||||
|
||||
function settingValuesEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
export function settingsEqual(left: ObsidianLiveSyncSettings, right: ObsidianLiveSyncSettings): boolean {
|
||||
return settingValuesEqual(left, right);
|
||||
}
|
||||
|
||||
function settingsKeys(...settings: ObsidianLiveSyncSettings[]): Set<keyof ObsidianLiveSyncSettings> {
|
||||
return new Set(settings.flatMap((value) => Object.keys(value) as Array<keyof ObsidianLiveSyncSettings>));
|
||||
}
|
||||
|
||||
function copySetting(
|
||||
target: ObsidianLiveSyncSettings,
|
||||
source: ObsidianLiveSyncSettings,
|
||||
key: keyof ObsidianLiveSyncSettings
|
||||
): void {
|
||||
const targetRecord = target as unknown as Record<string, unknown>;
|
||||
const sourceRecord = source as unknown as Record<string, unknown>;
|
||||
if (Object.prototype.hasOwnProperty.call(sourceRecord, key)) {
|
||||
targetRecord[key] = cloneJsonValue(sourceRecord[key]);
|
||||
} else {
|
||||
delete targetRecord[key];
|
||||
}
|
||||
}
|
||||
|
||||
function remoteSettingKeys(...settings: ObsidianLiveSyncSettings[]): Set<keyof ObsidianLiveSyncSettings> {
|
||||
const keys = new Set<keyof ObsidianLiveSyncSettings>([
|
||||
"remoteConfigurations",
|
||||
"activeConfigurationId",
|
||||
"P2P_ActiveRemoteConfigurationId",
|
||||
"remoteType",
|
||||
]);
|
||||
for (const current of settings) {
|
||||
for (const configuration of Object.values(current.remoteConfigurations ?? {})) {
|
||||
try {
|
||||
const parsed = ConnectionStringParser.parse(configuration.uri);
|
||||
for (const key of Object.keys(parsed.settings) as Array<keyof ObsidianLiveSyncSettings>) {
|
||||
keys.add(key);
|
||||
}
|
||||
} catch {
|
||||
// The setting service reports invalid remote configurations when loading them.
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function changedSettingKeys(
|
||||
before: ObsidianLiveSyncSettings,
|
||||
after: ObsidianLiveSyncSettings
|
||||
): Set<keyof ObsidianLiveSyncSettings> {
|
||||
const changed = new Set<keyof ObsidianLiveSyncSettings>();
|
||||
for (const key of settingsKeys(before, after)) {
|
||||
if (!settingValuesEqual(before[key], after[key])) {
|
||||
changed.add(key);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function isSettingsWriteCommand(command: CLICommand): boolean {
|
||||
return SETTINGS_WRITE_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
export function reconcileDurableSettings(options: {
|
||||
durableBase: ObsidianLiveSyncSettings;
|
||||
runtimeBaseline: ObsidianLiveSyncSettings;
|
||||
runtimeCurrent: ObsidianLiveSyncSettings;
|
||||
preserveKeys: ReadonlySet<keyof ObsidianLiveSyncSettings>;
|
||||
command?: CLICommand;
|
||||
}): ObsidianLiveSyncSettings {
|
||||
const durable = cloneSettings(options.durableBase);
|
||||
for (const key of settingsKeys(options.runtimeBaseline, options.runtimeCurrent)) {
|
||||
if (options.preserveKeys.has(key)) continue;
|
||||
if (!settingValuesEqual(options.runtimeBaseline[key], options.runtimeCurrent[key])) {
|
||||
copySetting(durable, options.runtimeCurrent, key);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.command) {
|
||||
if (REMOTE_SETTINGS_WRITE_COMMANDS.has(options.command)) {
|
||||
copySetting(durable, options.runtimeCurrent, "remoteConfigurations");
|
||||
copySetting(durable, options.runtimeCurrent, "activeConfigurationId");
|
||||
copySetting(durable, options.runtimeCurrent, "P2P_ActiveRemoteConfigurationId");
|
||||
|
||||
if (durable.activeConfigurationId) {
|
||||
activateRemoteConfiguration(durable, durable.activeConfigurationId);
|
||||
}
|
||||
} else {
|
||||
// Commands such as remote-status may activate a profile temporarily. Only
|
||||
// the dedicated remote settings commands are allowed to retain that switch.
|
||||
for (const key of remoteSettingKeys(options.durableBase, options.runtimeBaseline, options.runtimeCurrent)) {
|
||||
copySetting(durable, options.durableBase, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return durable;
|
||||
}
|
||||
|
||||
export function preserveStoredSetting(
|
||||
candidateText: string,
|
||||
originalText: string | undefined,
|
||||
key: keyof ObsidianLiveSyncSettings
|
||||
): string {
|
||||
if (originalText === undefined) return candidateText;
|
||||
try {
|
||||
const candidate = JSON.parse(candidateText) as ObsidianLiveSyncSettings;
|
||||
const original = JSON.parse(originalText) as ObsidianLiveSyncSettings;
|
||||
if (Object.prototype.hasOwnProperty.call(original, key)) {
|
||||
copySetting(candidate, original, key);
|
||||
} else {
|
||||
delete (candidate as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
return JSON.stringify(candidate, null, 2);
|
||||
} catch {
|
||||
return candidateText;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStoredSetting(
|
||||
target: ObsidianLiveSyncSettings,
|
||||
storedText: string | undefined,
|
||||
key: keyof ObsidianLiveSyncSettings
|
||||
): void {
|
||||
if (storedText === undefined) return;
|
||||
try {
|
||||
const stored = JSON.parse(storedText) as ObsidianLiveSyncSettings;
|
||||
if (Object.prototype.hasOwnProperty.call(stored, key)) {
|
||||
copySetting(target, stored, key);
|
||||
} else {
|
||||
delete (target as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
} catch {
|
||||
// The setting service owns validation and recovery of malformed files.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import {
|
||||
applyStoredSetting,
|
||||
changedSettingKeys,
|
||||
cloneSettings,
|
||||
isSettingsWriteCommand,
|
||||
preserveStoredSetting,
|
||||
reconcileDurableSettings,
|
||||
} from "./settingsPersistence";
|
||||
|
||||
function settings(overrides: Partial<ObsidianLiveSyncSettings> = {}): ObsidianLiveSyncSettings {
|
||||
return Object.assign(cloneSettings(DEFAULT_SETTINGS), overrides);
|
||||
}
|
||||
|
||||
describe("CLI settings persistence", () => {
|
||||
it("identifies commands which change the settings file automatically", () => {
|
||||
expect(isSettingsWriteCommand("setup")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-add")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-rm")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-set")).toBe(true);
|
||||
expect(isSettingsWriteCommand("remote-activate")).toBe(true);
|
||||
expect(isSettingsWriteCommand("ls")).toBe(false);
|
||||
expect(isSettingsWriteCommand("remote-status")).toBe(false);
|
||||
});
|
||||
|
||||
it("retains lasting changes without retaining CLI suspension values", () => {
|
||||
const durableBase = settings({
|
||||
liveSync: true,
|
||||
syncOnStart: true,
|
||||
periodicReplication: true,
|
||||
P2P_AutoStart: true,
|
||||
settingVersion: 9,
|
||||
customChunkSize: 40,
|
||||
});
|
||||
const runtimeBaseline = settings({
|
||||
...durableBase,
|
||||
liveSync: false,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
P2P_AutoStart: false,
|
||||
});
|
||||
const runtimeCurrent = settings({
|
||||
...runtimeBaseline,
|
||||
settingVersion: 10,
|
||||
customChunkSize: 60,
|
||||
});
|
||||
|
||||
const reconciled = reconcileDurableSettings({
|
||||
durableBase,
|
||||
runtimeBaseline,
|
||||
runtimeCurrent,
|
||||
preserveKeys: changedSettingKeys(durableBase, runtimeBaseline),
|
||||
command: "ls",
|
||||
});
|
||||
|
||||
expect(reconciled.liveSync).toBe(true);
|
||||
expect(reconciled.syncOnStart).toBe(true);
|
||||
expect(reconciled.periodicReplication).toBe(true);
|
||||
expect(reconciled.P2P_AutoStart).toBe(true);
|
||||
expect(reconciled.settingVersion).toBe(10);
|
||||
expect(reconciled.customChunkSize).toBe(60);
|
||||
});
|
||||
|
||||
it("retains a remote profile change and restores the durable sync values", () => {
|
||||
const durableBase = settings({
|
||||
liveSync: true,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
});
|
||||
const runtimeBaseline = settings({ ...durableBase, liveSync: false });
|
||||
const runtimeCurrent = settings({
|
||||
...runtimeBaseline,
|
||||
remoteConfigurations: {
|
||||
main: {
|
||||
id: "main",
|
||||
name: "Main",
|
||||
uri: "sls+https://user:pass@example.com/?db=notes",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "main",
|
||||
});
|
||||
activateRemoteConfiguration(runtimeCurrent, "main");
|
||||
|
||||
const reconciled = reconcileDurableSettings({
|
||||
durableBase,
|
||||
runtimeBaseline,
|
||||
runtimeCurrent,
|
||||
preserveKeys: changedSettingKeys(durableBase, runtimeBaseline),
|
||||
command: "remote-add",
|
||||
});
|
||||
|
||||
expect(reconciled.liveSync).toBe(true);
|
||||
expect(reconciled.activeConfigurationId).toBe("main");
|
||||
expect(reconciled.remoteConfigurations.main?.name).toBe("Main");
|
||||
expect(reconciled.couchDB_URI).toBe("https://example.com");
|
||||
expect(reconciled.couchDB_DBNAME).toBe("notes");
|
||||
});
|
||||
|
||||
it("does not retain a remote profile selected temporarily by an operational command", () => {
|
||||
const durableBase = settings({
|
||||
remoteConfigurations: {
|
||||
first: {
|
||||
id: "first",
|
||||
name: "First",
|
||||
uri: "sls+https://first:pass@example.com/?db=first",
|
||||
isEncrypted: false,
|
||||
},
|
||||
second: {
|
||||
id: "second",
|
||||
name: "Second",
|
||||
uri: "sls+https://second:pass@example.net/?db=second",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "first",
|
||||
});
|
||||
activateRemoteConfiguration(durableBase, "first");
|
||||
const runtimeBaseline = cloneSettings(durableBase);
|
||||
const runtimeCurrent = cloneSettings(durableBase);
|
||||
activateRemoteConfiguration(runtimeCurrent, "second");
|
||||
|
||||
const reconciled = reconcileDurableSettings({
|
||||
durableBase,
|
||||
runtimeBaseline,
|
||||
runtimeCurrent,
|
||||
preserveKeys: new Set(),
|
||||
command: "remote-status",
|
||||
});
|
||||
|
||||
expect(reconciled.activeConfigurationId).toBe("first");
|
||||
expect(reconciled.couchDB_URI).toBe("https://example.com");
|
||||
expect(reconciled.couchDB_USER).toBe("first");
|
||||
expect(reconciled.couchDB_DBNAME).toBe("first");
|
||||
});
|
||||
|
||||
it("preserves the stored adapter choice while the CLI uses its Node.js adapter", () => {
|
||||
const original = JSON.stringify({ useIndexedDBAdapter: true, settingVersion: 9 });
|
||||
const prepared = JSON.stringify({ useIndexedDBAdapter: false, settingVersion: 10 });
|
||||
const preserved = preserveStoredSetting(prepared, original, "useIndexedDBAdapter");
|
||||
const target = settings({ useIndexedDBAdapter: false });
|
||||
|
||||
applyStoredSetting(target, preserved, "useIndexedDBAdapter");
|
||||
|
||||
expect(JSON.parse(preserved).useIndexedDBAdapter).toBe(true);
|
||||
expect(target.useIndexedDBAdapter).toBe(true);
|
||||
});
|
||||
|
||||
it("does not add the CLI adapter override to an older settings file", () => {
|
||||
const original = JSON.stringify({ settingVersion: 9 });
|
||||
const prepared = JSON.stringify({ useIndexedDBAdapter: false, settingVersion: 10 });
|
||||
const preserved = preserveStoredSetting(prepared, original, "useIndexedDBAdapter");
|
||||
const target = settings({ useIndexedDBAdapter: false });
|
||||
|
||||
applyStoredSetting(target, preserved, "useIndexedDBAdapter");
|
||||
|
||||
expect(JSON.parse(preserved)).not.toHaveProperty("useIndexedDBAdapter");
|
||||
expect(target).not.toHaveProperty("useIndexedDBAdapter");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"test:daemon": "deno test --env-file=.test.env -A --no-check test-daemon.ts",
|
||||
"test:decoupled-vault": "deno test --env-file=.test.env -A --no-check test-decoupled-vault.ts",
|
||||
"test:remote-commands": "deno test --env-file=.test.env -A --no-check test-remote-commands.ts",
|
||||
"test:settings-writeback": "deno test -A --no-check test-settings-writeback.ts",
|
||||
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const TASKS = [
|
||||
"test:settings-writeback",
|
||||
"test:setup-put-cat",
|
||||
"test:mirror",
|
||||
"test:daemon",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { runCli } from "./helpers/cli.ts";
|
||||
import { initSettingsFile } from "./helpers/settings.ts";
|
||||
|
||||
async function prepareSettingsFixture(prefix: string) {
|
||||
const workDir = await TempDir.create(prefix);
|
||||
const settingsFile = workDir.join("settings.json");
|
||||
const databaseDir = workDir.join("database");
|
||||
await Deno.mkdir(databaseDir, { recursive: true });
|
||||
await initSettingsFile(settingsFile);
|
||||
return { workDir, settingsFile, databaseDir };
|
||||
}
|
||||
|
||||
Deno.test("settings-changing commands persist durable settings without CLI runtime suspension", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-command");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
settings.liveSync = true;
|
||||
settings.syncOnStart = true;
|
||||
settings.periodicReplication = true;
|
||||
settings.P2P_Enabled = true;
|
||||
settings.P2P_AutoStart = true;
|
||||
settings.P2P_AutoBroadcast = true;
|
||||
await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2));
|
||||
|
||||
const result = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-add",
|
||||
"test-remote",
|
||||
"sls+https://user:pass@example.com/database"
|
||||
);
|
||||
assertEquals(result.code, 0, result.combined);
|
||||
const firstRemoteId = result.stdout.trim().split("\t")[0];
|
||||
assert(firstRemoteId, `remote-add did not return an ID: ${result.combined}`);
|
||||
|
||||
let persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
let remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ name?: string }>;
|
||||
assert(
|
||||
remotes.some((remote) => remote.name === "test-remote"),
|
||||
"remote-add did not persist the new profile"
|
||||
);
|
||||
assertEquals(persisted.liveSync, true);
|
||||
assertEquals(persisted.syncOnStart, true);
|
||||
assertEquals(persisted.periodicReplication, true);
|
||||
assertEquals(persisted.P2P_Enabled, true);
|
||||
assertEquals(persisted.P2P_AutoStart, true);
|
||||
assertEquals(persisted.P2P_AutoBroadcast, true);
|
||||
|
||||
const secondAdd = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-add",
|
||||
"second-remote",
|
||||
"sls+https://other:secret@example.net/second"
|
||||
);
|
||||
assertEquals(secondAdd.code, 0, secondAdd.combined);
|
||||
const secondRemoteId = secondAdd.stdout.trim().split("\t")[0];
|
||||
assert(secondRemoteId, `second remote-add did not return an ID: ${secondAdd.combined}`);
|
||||
|
||||
const activate = await runCli(databaseDir, "--settings", settingsFile, "remote-activate", secondRemoteId);
|
||||
assertEquals(activate.code, 0, activate.combined);
|
||||
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
assertEquals(persisted.activeConfigurationId, secondRemoteId);
|
||||
|
||||
const set = await runCli(
|
||||
databaseDir,
|
||||
"--settings",
|
||||
settingsFile,
|
||||
"remote-set",
|
||||
secondRemoteId,
|
||||
"sls+https://replacement:secret@example.org/replaced"
|
||||
);
|
||||
assertEquals(set.code, 0, set.combined);
|
||||
const exported = await runCli(databaseDir, "--settings", settingsFile, "remote-export", secondRemoteId);
|
||||
assertEquals(exported.code, 0, exported.combined);
|
||||
assert(exported.stdout.includes("replacement"), "remote-set did not persist the replacement URI");
|
||||
|
||||
const remove = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", secondRemoteId);
|
||||
assertEquals(remove.code, 0, remove.combined);
|
||||
persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
remotes = Object.values(persisted.remoteConfigurations ?? {}) as Array<{ id?: string }>;
|
||||
assert(!remotes.some((remote) => remote.id === secondRemoteId), "remote-rm did not persist the removal");
|
||||
assertEquals(persisted.activeConfigurationId, firstRemoteId);
|
||||
});
|
||||
|
||||
Deno.test("ordinary commands keep the settings file unchanged by default", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-readonly");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
settings.settingVersion = 9;
|
||||
const original = JSON.stringify(settings, null, 2);
|
||||
await Deno.writeTextFile(settingsFile, original);
|
||||
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "ls");
|
||||
assertEquals(result.code, 0, result.combined);
|
||||
assertEquals(await Deno.readTextFile(settingsFile), original);
|
||||
});
|
||||
|
||||
Deno.test("--write-settings persists durable start-up setting changes", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-explicit");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const settings = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
settings.settingVersion = 9;
|
||||
delete settings.useIndexedDBAdapter;
|
||||
await Deno.writeTextFile(settingsFile, JSON.stringify(settings, null, 2));
|
||||
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "--write-settings", "ls");
|
||||
assertEquals(result.code, 0, result.combined);
|
||||
|
||||
const persisted = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
assertEquals(persisted.settingVersion, 10);
|
||||
assert(!("useIndexedDBAdapter" in persisted), "the CLI-only adapter override was written to the settings file");
|
||||
});
|
||||
|
||||
Deno.test("failed settings-changing commands leave the settings file unchanged", async () => {
|
||||
const fixture = await prepareSettingsFixture("livesync-cli-settings-failure");
|
||||
await using workDir = fixture.workDir;
|
||||
const { settingsFile, databaseDir } = fixture;
|
||||
|
||||
const original = await Deno.readTextFile(settingsFile);
|
||||
const result = await runCli(databaseDir, "--settings", settingsFile, "remote-rm", "missing-remote");
|
||||
assert(result.code !== 0, "remote-rm unexpectedly succeeded");
|
||||
assertEquals(await Deno.readTextFile(settingsFile), original);
|
||||
});
|
||||
@@ -41,6 +41,13 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev
|
||||
setupResult.combined.includes("[Command] setup ->"),
|
||||
`setup command did not execute expected code path\n${setupResult.combined}`
|
||||
);
|
||||
const persistedSetup = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
assertEquals(persistedSetup.isConfigured, true, "setup did not persist the configured state");
|
||||
assert(
|
||||
typeof persistedSetup.encryptedCouchDBConnection === "string" &&
|
||||
persistedSetup.encryptedCouchDBConnection.length > 0,
|
||||
"setup did not persist the encrypted connection settings"
|
||||
);
|
||||
|
||||
const run = (...args: string[]) => runCliOrFail(vaultDir, "--settings", settingsFile, ...args);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.2-webapp",
|
||||
"version": "1.0.4-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.2-webpeer",
|
||||
"version": "1.0.4-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
@@ -9,7 +8,8 @@ import type { LiveSyncCore } from "@/main";
|
||||
export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (!defaultRemoteProviderRegistry.isRemoteTypeInFamily(settings.remoteType, "couchdb")) {
|
||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
||||
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
|
||||
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
|
||||
if (defaultRemoteProviderRegistry.isRemoteTypeInFamily(this.settings.remoteType, "couchdb")) {
|
||||
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
|
||||
const LiveSyncEnabled = this.settings.liveSync;
|
||||
const continuous = LiveSyncEnabled;
|
||||
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
|
||||
|
||||
function createModule(
|
||||
settings: { liveSync: boolean; syncOnStart: boolean; remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO },
|
||||
isReplicationReady = true
|
||||
) {
|
||||
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const services = {
|
||||
@@ -34,7 +30,7 @@ function createModule(
|
||||
_services: services,
|
||||
services,
|
||||
settings: {
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
remoteType: "",
|
||||
...settings,
|
||||
},
|
||||
replicator: { openReplication },
|
||||
@@ -90,18 +86,4 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start CouchDB replication for a registered Journal provider", async () => {
|
||||
const { module, openReplication } = createModule({
|
||||
liveSync: true,
|
||||
syncOnStart: true,
|
||||
remoteType: REMOTE_MINIO,
|
||||
});
|
||||
|
||||
await expect(module._anyNewReplicator()).resolves.toBe(false);
|
||||
await module._everyAfterResumeProcess();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
|
||||
export class ModuleReplicatorMinIO extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type ObsidianLiveSyncSettings,
|
||||
@@ -18,12 +21,18 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
||||
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
|
||||
import {
|
||||
activateRemoteConfiguration,
|
||||
createRemoteConfigurationId,
|
||||
defaultRemoteProviderRegistry,
|
||||
suggestRemoteConfigurationName,
|
||||
type BuiltInRemoteConfiguration,
|
||||
type RemoteConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import type {
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
|
||||
|
||||
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
||||
@@ -34,6 +43,10 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
|
||||
}
|
||||
return workObj;
|
||||
}
|
||||
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> {
|
||||
@@ -41,10 +54,13 @@ function cloneRemoteConfigurations(
|
||||
}
|
||||
|
||||
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
|
||||
const type = defaultRemoteProviderRegistry.typeForRemoteType(settings.remoteType);
|
||||
if (!type) throw new Error(`Unsupported remote type: ${settings.remoteType}`);
|
||||
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
|
||||
return defaultRemoteProviderRegistry.serialise(configuration);
|
||||
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: ButtonComponent, emoji: string, tooltip: string) {
|
||||
@@ -55,6 +71,21 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
|
||||
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,
|
||||
@@ -158,10 +189,48 @@ export function paneRemoteConfig(
|
||||
};
|
||||
const runRemoteSetup = async (
|
||||
baseSettings: ObsidianLiveSyncSettings,
|
||||
type?: BuiltInRemoteConfiguration["type"]
|
||||
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
|
||||
): Promise<ObsidianLiveSyncSettings | false> => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
return await setupManager.configureRemoteForSettings(baseSettings, type);
|
||||
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<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, {
|
||||
settings: baseSettings,
|
||||
mode: "settings",
|
||||
});
|
||||
if (couchConf === "cancelled" || typeof couchConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
|
||||
};
|
||||
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -213,9 +282,9 @@ export function paneRemoteConfig(
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: BuiltInRemoteConfiguration;
|
||||
let parsed: RemoteConfigurationResult;
|
||||
try {
|
||||
parsed = defaultRemoteProviderRegistry.parse(trimmedURI);
|
||||
parsed = ConnectionStringParser.parse(trimmedURI);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
|
||||
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
||||
@@ -233,7 +302,7 @@ export function paneRemoteConfig(
|
||||
configs[id] = {
|
||||
id,
|
||||
name: name.trim() || defaultName,
|
||||
uri: defaultRemoteProviderRegistry.serialise(parsed),
|
||||
uri: ConnectionStringParser.serialize(parsed),
|
||||
isEncrypted: false,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = configs;
|
||||
@@ -268,9 +337,9 @@ export function paneRemoteConfig(
|
||||
|
||||
row.addButton((btn) =>
|
||||
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
|
||||
let parsed: BuiltInRemoteConfiguration;
|
||||
let parsed: RemoteConfigurationResult;
|
||||
try {
|
||||
parsed = defaultRemoteProviderRegistry.parse(config.uri);
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(
|
||||
`Failed to parse remote configuration '${config.id}' for editing!`,
|
||||
@@ -280,9 +349,16 @@ export function paneRemoteConfig(
|
||||
return;
|
||||
}
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
|
||||
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, parsed.type);
|
||||
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
|
||||
if (!nextSettings) {
|
||||
return;
|
||||
}
|
||||
@@ -371,9 +447,9 @@ export function paneRemoteConfig(
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("📡 Fetch remote settings").onClick(async () => {
|
||||
let parsed: BuiltInRemoteConfiguration;
|
||||
let parsed: RemoteConfigurationResult;
|
||||
try {
|
||||
parsed = defaultRemoteProviderRegistry.parse(config.uri);
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(
|
||||
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
|
||||
@@ -383,7 +459,14 @@ export function paneRemoteConfig(
|
||||
return;
|
||||
}
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
|
||||
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 newTweaks =
|
||||
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
|
||||
workSettings
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
defaultRemoteProviderRegistry,
|
||||
upsertRemoteConfigurationInPlace,
|
||||
type BuiltInRemoteConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
@@ -20,6 +20,9 @@ import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
|
||||
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
|
||||
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
|
||||
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";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
@@ -28,8 +31,11 @@ import type {
|
||||
OutroExistingUserResultType,
|
||||
OutroNewUserResultType,
|
||||
ScanQRCodeResultType,
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteInitialData,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
UseSetupURIResultType,
|
||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
@@ -38,8 +44,6 @@ import {
|
||||
applySettingsWithScheduledInitialisation,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { builtInRemoteSetupRegistry } from "./SetupWizard/builtInRemoteSetupProviders.ts";
|
||||
import type { RemoteSetupIntent } from "./SetupWizard/RemoteSetupRegistry.ts";
|
||||
|
||||
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
@@ -75,8 +79,6 @@ export const enum UserMode {
|
||||
* Setup Manager to handle onboarding and configuration setup
|
||||
*/
|
||||
export class SetupManager extends AbstractModule {
|
||||
readonly remoteSetupRegistry = builtInRemoteSetupRegistry;
|
||||
|
||||
// /**
|
||||
// * Dialog manager for handling Svelte dialogs
|
||||
// */
|
||||
@@ -155,76 +157,6 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||
}
|
||||
|
||||
private setupIntent(userMode: UserMode): RemoteSetupIntent {
|
||||
if (userMode === UserMode.NewUser) return "create-or-connect";
|
||||
if (userMode === UserMode.ExistingUser) return "connect-existing";
|
||||
return "settings";
|
||||
}
|
||||
|
||||
private async selectRemoteSetupProvider(): Promise<BuiltInRemoteConfiguration["type"] | false> {
|
||||
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType, SetupRemoteInitialData>(
|
||||
SetupRemote,
|
||||
this.remoteSetupRegistry.choices()
|
||||
);
|
||||
return method === "cancelled" ? false : method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a provider-specific dialogue without applying or storing the resulting settings.
|
||||
*
|
||||
* The settings pane uses this boundary to prepare a remote profile in its own edit buffer.
|
||||
*/
|
||||
async configureRemoteForSettings(
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
type?: BuiltInRemoteConfiguration["type"]
|
||||
): Promise<ObsidianLiveSyncSettings | false> {
|
||||
const selectedType = type ?? (await this.selectRemoteSetupProvider());
|
||||
if (selectedType === false) return false;
|
||||
|
||||
const configuration = await this.remoteSetupRegistry.open(selectedType, {
|
||||
dialogManager: this.dialogManager,
|
||||
intent: "settings",
|
||||
settings: currentSetting,
|
||||
});
|
||||
if (configuration === "cancelled") return false;
|
||||
|
||||
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
|
||||
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
|
||||
return newSetting;
|
||||
}
|
||||
|
||||
private async onRemoteManualSetup(
|
||||
type: BuiltInRemoteConfiguration["type"],
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate: boolean
|
||||
): Promise<boolean> {
|
||||
const configuration = await this.remoteSetupRegistry.open(type, {
|
||||
dialogManager: this.dialogManager,
|
||||
intent: this.setupIntent(userMode),
|
||||
settings: currentSetting,
|
||||
});
|
||||
if (configuration === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
|
||||
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
|
||||
if (activate) {
|
||||
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
|
||||
} else {
|
||||
Object.assign(newSetting, configuration.settings);
|
||||
}
|
||||
|
||||
const activateForP2P = defaultRemoteProviderRegistry.supportsActivationRole(type, "p2p");
|
||||
upsertRemoteConfigurationInPlace(newSetting, type, {
|
||||
id: activateForP2P ? newSetting.P2P_ActiveRemoteConfigurationId || undefined : undefined,
|
||||
activate,
|
||||
activateForP2P,
|
||||
});
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for CouchDB
|
||||
* @param userMode
|
||||
@@ -237,7 +169,31 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
return await this.onRemoteManualSetup("couchdb", userMode, currentSetting, activate);
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, {
|
||||
settings: currentSetting,
|
||||
mode:
|
||||
userMode === UserMode.NewUser
|
||||
? "create-or-connect"
|
||||
: userMode === UserMode.ExistingUser
|
||||
? "connect-existing"
|
||||
: "settings",
|
||||
});
|
||||
if (couchConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...couchConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +208,23 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
|
||||
const bucketConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteBucketResultType,
|
||||
BucketSyncSetting
|
||||
>(SetupRemoteBucket, currentSetting);
|
||||
if (bucketConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...bucketConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,7 +239,24 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
return await this.onRemoteManualSetup("p2p", userMode, currentSetting, activate);
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||
SetupRemoteP2P,
|
||||
currentSetting
|
||||
);
|
||||
if (p2pConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...p2pConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
|
||||
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
|
||||
activate,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,15 +310,21 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const method = await this.selectRemoteSetupProvider();
|
||||
if (method === false) {
|
||||
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
|
||||
if (method === "couchdb") {
|
||||
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "bucket") {
|
||||
return await this.onBucketManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "p2p") {
|
||||
return await this.onP2PManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
if (userMode !== UserMode.Unknown) {
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return await this.onRemoteManualSetup(method, userMode, currentSetting, true);
|
||||
// Should not reach here.
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Confirms and applies settings obtained from the wizard
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -422,40 +421,6 @@ describe("SetupManager", () => {
|
||||
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
|
||||
});
|
||||
|
||||
it("uses the registered provider choices when configuring a Settings edit buffer", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const bucketSettings = {
|
||||
endpoint: "https://storage.example",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
bucket: "notes",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
};
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("s3").mockResolvedValueOnce(bucketSettings);
|
||||
|
||||
const nextSettings = await manager.configureRemoteForSettings(setting.currentSettings());
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "couchdb" }),
|
||||
expect.objectContaining({ type: "s3" }),
|
||||
expect.objectContaining({ type: "p2p" }),
|
||||
])
|
||||
);
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
setting.currentSettings()
|
||||
);
|
||||
expect(nextSettings).toEqual(expect.objectContaining({ ...bucketSettings, remoteType: REMOTE_MINIO }));
|
||||
});
|
||||
|
||||
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
BuiltInRemoteConfiguration,
|
||||
RemoteProviderConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
|
||||
export type RemoteSetupIntent = "create-or-connect" | "connect-existing" | "settings";
|
||||
|
||||
export interface RemoteSetupDialogManager {
|
||||
openWithExplicitCancel<TResult, TInitial = TResult>(
|
||||
component: ComponentHasResult<TResult, TInitial>,
|
||||
initialData?: TInitial
|
||||
): Promise<TResult>;
|
||||
}
|
||||
|
||||
export interface RemoteSetupContext {
|
||||
readonly dialogManager: RemoteSetupDialogManager;
|
||||
readonly intent: RemoteSetupIntent;
|
||||
readonly settings: ObsidianLiveSyncSettings;
|
||||
}
|
||||
|
||||
export interface RemoteSetupChoice<TType extends string = string> {
|
||||
readonly description: string;
|
||||
readonly proceedTitle: string;
|
||||
readonly title: string;
|
||||
readonly type: TType;
|
||||
}
|
||||
|
||||
export interface RemoteSetupProviderDescriptor<
|
||||
TConfiguration extends RemoteProviderConfiguration = RemoteProviderConfiguration,
|
||||
> {
|
||||
readonly type: TConfiguration["type"];
|
||||
choice(): Omit<RemoteSetupChoice<TConfiguration["type"]>, "type">;
|
||||
open(context: RemoteSetupContext): Promise<TConfiguration | "cancelled">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-side presentation for the remote providers compiled into this plug-in.
|
||||
*
|
||||
* Connection semantics remain in Commonlib's remote provider registry. This registry only owns
|
||||
* the Obsidian-specific choice text and setup dialogue for each provider.
|
||||
*/
|
||||
export class RemoteSetupRegistry<TConfiguration extends RemoteProviderConfiguration = BuiltInRemoteConfiguration> {
|
||||
private readonly providersByType = new Map<string, RemoteSetupProviderDescriptor<TConfiguration>>();
|
||||
private frozen = false;
|
||||
|
||||
register<TProviderConfiguration extends TConfiguration>(
|
||||
descriptor: RemoteSetupProviderDescriptor<TProviderConfiguration>
|
||||
): this {
|
||||
if (this.frozen) throw new Error("The remote setup registry is frozen");
|
||||
if (this.providersByType.has(descriptor.type)) {
|
||||
throw new Error(`Remote setup provider '${descriptor.type}' is already registered`);
|
||||
}
|
||||
this.providersByType.set(descriptor.type, descriptor);
|
||||
return this;
|
||||
}
|
||||
|
||||
freeze(): this {
|
||||
this.frozen = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
isFrozen(): boolean {
|
||||
return this.frozen;
|
||||
}
|
||||
|
||||
choices(): RemoteSetupChoice<TConfiguration["type"]>[] {
|
||||
return [...this.providersByType.values()].map((provider) => ({
|
||||
...provider.choice(),
|
||||
type: provider.type,
|
||||
}));
|
||||
}
|
||||
|
||||
has(type: string): type is TConfiguration["type"] {
|
||||
return this.providersByType.has(type);
|
||||
}
|
||||
|
||||
open(type: TConfiguration["type"], context: RemoteSetupContext): Promise<TConfiguration | "cancelled"> {
|
||||
const provider = this.providersByType.get(type);
|
||||
if (!provider) throw new Error(`Unsupported remote setup provider: ${type}`);
|
||||
return provider.open(context);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||
|
||||
type TestConfiguration =
|
||||
| { type: "alpha"; settings: { value: string } }
|
||||
| { type: "beta"; settings: { enabled: boolean } };
|
||||
|
||||
function alphaProvider(
|
||||
open: RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>>["open"] = async () => ({
|
||||
type: "alpha",
|
||||
settings: { value: "configured" },
|
||||
})
|
||||
): RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>> {
|
||||
return {
|
||||
type: "alpha",
|
||||
choice: () => ({
|
||||
title: "Alpha",
|
||||
description: "Alpha description",
|
||||
proceedTitle: "Configure Alpha",
|
||||
}),
|
||||
open,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoteSetupRegistry", () => {
|
||||
it("preserves registration order and dispatches setup through the selected provider", async () => {
|
||||
const openAlpha = vi.fn(alphaProvider().open);
|
||||
const registry = new RemoteSetupRegistry<TestConfiguration>()
|
||||
.register(alphaProvider(openAlpha))
|
||||
.register({
|
||||
type: "beta",
|
||||
choice: () => ({
|
||||
title: "Beta",
|
||||
description: "Beta description",
|
||||
proceedTitle: "Configure Beta",
|
||||
}),
|
||||
open: async () => ({ type: "beta", settings: { enabled: true } }),
|
||||
})
|
||||
.freeze();
|
||||
const context = {
|
||||
dialogManager: { openWithExplicitCancel: vi.fn() },
|
||||
intent: "settings" as const,
|
||||
settings: {} as never,
|
||||
};
|
||||
|
||||
expect(registry.choices().map((choice) => choice.type)).toEqual(["alpha", "beta"]);
|
||||
await expect(registry.open("alpha", context)).resolves.toEqual({
|
||||
type: "alpha",
|
||||
settings: { value: "configured" },
|
||||
});
|
||||
expect(openAlpha).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
it("rejects duplicate and late registrations", () => {
|
||||
const registry = new RemoteSetupRegistry<TestConfiguration>().register(alphaProvider());
|
||||
|
||||
expect(() => registry.register(alphaProvider())).toThrow("already registered");
|
||||
|
||||
registry.freeze();
|
||||
expect(registry.isFrozen()).toBe(true);
|
||||
expect(() =>
|
||||
registry.register({
|
||||
type: "beta",
|
||||
choice: () => ({ title: "Beta", description: "", proceedTitle: "" }),
|
||||
open: async () => "cancelled",
|
||||
})
|
||||
).toThrow("frozen");
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { BucketSyncSetting, P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
|
||||
import type {
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
} from "./dialogs/setupDialogTypes";
|
||||
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||
|
||||
type ConfigurationOf<TType extends BuiltInRemoteConfiguration["type"]> = Extract<
|
||||
BuiltInRemoteConfiguration,
|
||||
{ type: TType }
|
||||
>;
|
||||
|
||||
function assertSemanticProvider(type: BuiltInRemoteConfiguration["type"]): void {
|
||||
const isRegistered = defaultRemoteProviderRegistry.providerSummaries().some((provider) => provider.type === type);
|
||||
if (!isRegistered) throw new Error(`Remote setup provider '${type}' has no Commonlib provider`);
|
||||
}
|
||||
|
||||
export function useCouchDBRemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"couchdb">> = {
|
||||
type: "couchdb",
|
||||
choice: () => ({
|
||||
title: "CouchDB",
|
||||
description: translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc"),
|
||||
proceedTitle: translateMessage("Continue to CouchDB setup"),
|
||||
}),
|
||||
open: async ({ dialogManager, intent, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, { settings, mode: intent });
|
||||
return result === "cancelled" ? result : { type: "couchdb", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function useS3RemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"s3">> = {
|
||||
type: "s3",
|
||||
choice: () => ({
|
||||
title: translateMessage("Ui.SetupWizard.SetupRemote.BucketOption"),
|
||||
description: translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc"),
|
||||
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket"),
|
||||
}),
|
||||
open: async ({ dialogManager, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<SetupRemoteBucketResultType, BucketSyncSetting>(
|
||||
SetupRemoteBucket,
|
||||
settings
|
||||
);
|
||||
return result === "cancelled" ? result : { type: "s3", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function useP2PRemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"p2p">> = {
|
||||
type: "p2p",
|
||||
choice: () => ({
|
||||
title: translateMessage("Ui.SetupWizard.SetupRemote.P2POption"),
|
||||
description: translateMessage(
|
||||
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
||||
),
|
||||
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P"),
|
||||
}),
|
||||
open: async ({ dialogManager, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||
SetupRemoteP2P,
|
||||
settings
|
||||
);
|
||||
return result === "cancelled" ? result : { type: "p2p", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
|
||||
useCouchDBRemoteSetup(registry);
|
||||
useS3RemoteSetup(registry);
|
||||
useP2PRemoteSetup(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export const builtInRemoteSetupRegistry = createBuiltInRemoteSetupRegistry().freeze();
|
||||
@@ -7,33 +7,58 @@
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { onMount } from "svelte";
|
||||
import { type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { TYPE_CANCELLED, type SetupRemoteInitialData, type SetupRemoteResultType } from "./setupDialogTypes";
|
||||
import {
|
||||
TYPE_COUCHDB,
|
||||
TYPE_BUCKET,
|
||||
TYPE_P2P,
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteResultType,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteResultType, SetupRemoteInitialData>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let choices = $state<SetupRemoteInitialData>([]);
|
||||
type Props = {
|
||||
setResult: (result: SetupRemoteResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
|
||||
onMount(() => {
|
||||
choices = getInitialData?.() ?? [];
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_COUCHDB) {
|
||||
return translateMessage("Continue to CouchDB setup");
|
||||
} else if (userType === TYPE_BUCKET) {
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
|
||||
} else if (userType === TYPE_P2P) {
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
|
||||
} else {
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
|
||||
});
|
||||
const selectedChoice = $derived(choices.find((choice) => choice.type === userType));
|
||||
const proceedTitle = $derived(
|
||||
selectedChoice?.proceedTitle ?? translateMessage("Please select an option to proceed")
|
||||
);
|
||||
const canProceed = $derived(selectedChoice !== undefined);
|
||||
</script>
|
||||
|
||||
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
|
||||
<Instruction>
|
||||
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
|
||||
<Options>
|
||||
{#each choices as choice (choice.type)}
|
||||
<Option selectedValue={choice.type} title={choice.title} bind:value={userType}>
|
||||
{choice.description}
|
||||
</Option>
|
||||
{/each}
|
||||
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
|
||||
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_BUCKET}
|
||||
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_P2P}
|
||||
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage(
|
||||
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
||||
)}
|
||||
</Option>
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
|
||||
@@ -5,8 +5,6 @@ import type {
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
|
||||
|
||||
export const TYPE_IDENTICAL = "identical";
|
||||
export const TYPE_INDEPENDENT = "independent";
|
||||
@@ -40,7 +38,7 @@ export const TYPE_CLOSE = "close";
|
||||
|
||||
// SetupRemote
|
||||
export const TYPE_COUCHDB = "couchdb";
|
||||
export const TYPE_BUCKET = "s3";
|
||||
export const TYPE_BUCKET = "bucket";
|
||||
export const TYPE_P2P = "p2p";
|
||||
|
||||
export type ResultTypeVault =
|
||||
@@ -95,8 +93,7 @@ export type SelectMethodExistingResultType =
|
||||
| typeof TYPE_CONFIGURE_MANUALLY
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type SetupRemoteResultType = BuiltInRemoteConfiguration["type"] | typeof TYPE_CANCELLED;
|
||||
export type SetupRemoteInitialData = RemoteSetupChoice<BuiltInRemoteConfiguration["type"]>[];
|
||||
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
||||
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
|
||||
@@ -147,9 +147,14 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
: req instanceof Request && typeof req.method === "string"
|
||||
? req.method
|
||||
: "GET";
|
||||
const suppliedBody = opts?.body ?? (req instanceof Request ? req.body : undefined);
|
||||
if (suppliedBody !== undefined && suppliedBody !== null) {
|
||||
body = typeof suppliedBody === "string" ? suppliedBody : await new Response(suppliedBody).arrayBuffer();
|
||||
if (typeof req !== "string") {
|
||||
if (opts?.body) {
|
||||
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
|
||||
} else if (req.body) {
|
||||
body = await new Response(req.body).arrayBuffer();
|
||||
}
|
||||
} else {
|
||||
body = opts?.body as string;
|
||||
}
|
||||
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
|
||||
|
||||
@@ -187,9 +192,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
contentType: contentType,
|
||||
};
|
||||
const r = await requestUrl({ ...requestParam, throw: false });
|
||||
const responseHasNoBody =
|
||||
method.toUpperCase() === "HEAD" || r.status === 204 || r.status === 205 || r.status === 304;
|
||||
return new Response(responseHasNoBody ? null : r.arrayBuffer, {
|
||||
return new Response(r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
|
||||
@@ -4,17 +4,16 @@ const mocks = vi.hoisted(() => ({
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: mocks.requestUrl,
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: mocks.requestUrl,
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
@@ -66,41 +65,3 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.nativeFetch", () => {
|
||||
it("normalises a typed-array body when the request URL is a string", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
arrayBuffer: new Uint8Array([9, 8, 7]).buffer,
|
||||
headers: { etag: '"created"' },
|
||||
status: 201,
|
||||
});
|
||||
const source = new Uint8Array([0, 1, 2, 3, 4]);
|
||||
const body = source.subarray(1, 4);
|
||||
|
||||
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
|
||||
body: body as unknown as BodyInit,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const request = mocks.requestUrl.mock.calls[0][0] as { body?: unknown };
|
||||
expect(request.body).toBeInstanceOf(ArrayBuffer);
|
||||
expect([...new Uint8Array(request.body as ArrayBuffer)]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("constructs a bodyless response for a successful DELETE", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {},
|
||||
status: 204,
|
||||
});
|
||||
|
||||
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
+24
-3
@@ -12,11 +12,32 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
### P2P and experimental browser applications
|
||||
## 1.0.4
|
||||
|
||||
#### Improved
|
||||
5th August, 2026
|
||||
|
||||
- WebPeer now includes a separate P2P connection check which prepares a disposable encrypted Setup URI and QR code locally, tests desktop and mobile devices in separate random rooms, can reuse the unchanged QR and a recorded baseline for an optional second device in the same room, and displays browser-side WebRTC diagnostic totals without presenting them as note-synchronisation results.
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Testing or saving a fresh remote configuration no longer tries to access the local database while constructing a replicator, avoiding 'Local database is not ready yet' failures before local database initialisation (#1064).
|
||||
|
||||
### Command-line tool
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Successful setup and remote-configuration commands now retain their settings changes. Other commands leave the settings file unchanged unless `--write-settings` is supplied, and temporary CLI suspension values are never written (#1070).
|
||||
|
||||
## 1.0.3
|
||||
|
||||
3rd August, 2026
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- File consistency checks no longer read older revisions after the current Vault content matches known synchronised history, avoiding unnecessary 'Missing document content' warnings from obsolete unreadable revisions.
|
||||
- Remote chunk fetching now retains chunks which were returned successfully when another chunk in the same request is unavailable, so the available content can still be processed (#771).
|
||||
|
||||
### Interface
|
||||
|
||||
|
||||
+3
-1
@@ -14,5 +14,7 @@
|
||||
"1.0.0-rc.1": "1.7.2",
|
||||
"1.0.0": "1.7.2",
|
||||
"1.0.1": "1.7.2",
|
||||
"1.0.2": "1.7.2"
|
||||
"1.0.2": "1.7.2",
|
||||
"1.0.3": "1.7.2",
|
||||
"1.0.4": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user