Compare commits

...

33 Commits

Author SHA1 Message Date
vorotamoroz
33c01fdf1e bump 2025-11-06 09:43:58 +00:00
vorotamoroz
536c0426d6 Update lib for switch Refiner to Computed 2025-11-06 09:40:37 +00:00
vorotamoroz
2f848878c2 Add doc 2025-11-06 09:24:25 +00:00
vorotamoroz
c4f2baef5e ### Fixed
#### JWT Authentication

- Now we can use JWT Authentication ES512 correctly (#742).
- Several misdirections in the Setting dialogues have been fixed (i.e., seconds and minutes confusion...).
- The key area in the Setting dialogue has been enlarged and accepts newlines correctly.
- Caching of JWT tokens now works correctly
    - Tokens are now cached and reused until they expire.
    - They will be kept until 10% of the expiration duration is remaining or 10 seconds, whichever is longer (but at a maximum of 1 minute).
- JWT settings are now correctly displayed on the Setting dialogue.

#### Other fixes

- Receiving non-latest revisions no longer causes unexpected overwrites.
    - On receiving revisions that made conflicting changes, we are still able to handle them.

### Improved

- No longer duplicated message notifications are shown when a connection to the remote server fails.
    - Instead, a single notification is shown, and it will be kept on the notification area inside the editor until the situation is resolved.
- The notification area is no longer imposing, distracting, and overwhelming.
    - With a pale background, but bordered and with icons.
2025-11-06 09:24:16 +00:00
vorotamoroz
a5b88a8d47 Add tips 2025-11-06 02:10:12 +00:00
vorotamoroz
88e61fb41f Merge branch 'main' of https://github.com/vrtmrz/obsidian-livesync 2025-11-04 11:36:36 +00:00
vorotamoroz
9bf04332bb bump 2025-11-04 11:35:19 +00:00
vorotamoroz
5238dec3f2 fix: Now hidden file synchronisation respects the filters correctly (#631, #735) 2025-11-04 11:34:39 +00:00
vorotamoroz
2b7b411c52 Merge branch 'svelteui' 2025-11-04 11:09:22 +00:00
vorotamoroz
aab0f7f034 Fix Backporting mis 2025-11-04 10:58:46 +00:00
vorotamoroz
b3a0deb0e3 bump 2025-10-31 11:36:55 +01:00
vorotamoroz
b9138d1395 ### Fixed
- We can enter the fields on some dialogue correctly on mobile devices now.

### New features

- We can use TURN server for P2P connections now.
2025-10-31 11:33:06 +01:00
vorotamoroz
04997b84c0 Merge pull request #740 from shaielc/_local
Default to _local when node not supplied
2025-10-30 17:58:24 +09:00
vorotamoroz
7eb9807aa5 Fix import path 2025-10-30 09:35:17 +01:00
vorotamoroz
91a4f234f1 bump 2025-10-30 09:30:38 +01:00
vorotamoroz
82f2860938 ### Fixed
- P2P Replication got more robust and stable.

### Breaking changes

- Send configuration via Peer-to-Peer connection is not compatible with older versions.
2025-10-30 09:29:51 +01:00
shyelc
41a112cd8a Default to _local when node not supplied 2025-10-30 07:27:52 +00:00
vorotamoroz
294ebf0c31 Add automatic node detection 2025-10-30 13:24:01 +09:00
vorotamoroz
5443317157 merged 2025-10-30 02:38:30 +01:00
vorotamoroz
47fe9d2af3 Adjust method name to actual behaviour 2025-10-30 02:28:18 +01:00
vorotamoroz
4c260a7d2b Merge pull request #723 from GrzybowskiBYD/patch-1
Fix an issue with CouchDB while using couchdb-init.sh
2025-10-29 10:35:00 +09:00
vorotamoroz
82f6fefd35 bump 2025-10-26 19:51:20 +09:00
vorotamoroz
ada8001fcb ### Fixed
- We are now able to enable optional features correctly again (#732).
- No longer oversized files have been processed, furthermore.
  - Before creating a chunk, the file is verified as the target.
  - The behaviour upon receiving replication has been changed as follows:
    - If the remote file is oversized, it is ignored.
    - If not, but while the local file is oversized, it is also ignored.
2025-10-26 19:38:45 +09:00
vorotamoroz
8b81570035 Grammatically fixes 2025-10-22 14:02:52 +01:00
vorotamoroz
d3e50421e4 Merge branch 'svelteui' of https://github.com/vrtmrz/obsidian-livesync into svelteui 2025-10-22 14:02:20 +01:00
vorotamoroz
12605f4604 Add updates 2025-10-22 14:02:00 +01:00
vorotamoroz
2c0dd82886 Add updates 2025-10-22 13:56:24 +01:00
vorotamoroz
f5315aacb8 v0.25.23.beta1
### Fixed (This should be backported to 0.25.22 if the beta phase is prolonged)

- No longer larger files will not create a chunks during preparing `Reset Synchronisation on This Device`.

### Behaviour changes

- Setup wizard is now more `goal-oriented`. Brand-new screens are introduced.
- `Fetch everything` and `Rebuild everything` is now `Reset Synchronisation on This Device` and `Overwrite Server Data with This Device's Files`.
- Remote configuration and E2EE settings are now separated to each modal dialogue.
- Peer-to-Peer settings is also separated into its own modal dialogue.
- Setup-URI, and Report for the Issue are now not copied to clipboard automatically. Instead, there are copy dialogue and buttons to copy them explicitly.
- No longer optional features are introduced during the setup or `Reset Synchronisation on This Device`, `Overwrite Server Data with This Device's Files`.
- We cannot preform `Fetch everything` and `Rebuild everything` (Removed, so the old name) without restarting Obsidian now.

### Miscellaneous

- Setup QR Code generation is separated into a src/lib/src/API/processSetting.ts file. Please use it as a subrepository if you want to generate QR codes in your own application.
- Setup-URI is also separated into a src/lib/src/API/processSetting.ts
- Some direct access to web-APIs are now wrapped into the services layer.

### Dependency updates

- Many dependencies are updated. Please see `package.json`.
- As upgrading TypeScript, Fixed many UInt8Array<ArrayBuffer> and Uint8Array type mismatches.
2025-10-22 13:56:15 +01:00
vorotamoroz
5a93066870 bump 2025-10-15 01:02:24 +09:00
vorotamoroz
3a73073505 ### Fixed
- Fixed a bug that caused wrong event bindings and flag inversion (#727)
  - This caused following issues:
    - In some cases, settings changes were not applied or saved correctly.
    - Automatic synchronisation did not begin correctly.

### Improved
- Too large diffs are not shown in the file comparison view, due to performance reasons.
2025-10-15 01:00:24 +09:00
vorotamoroz
ee0c0ee611 Add a note to prevent my forget 2025-10-13 11:27:03 +09:00
vorotamoroz
d7ea30e304 Merge pull request #726 from vrtmrz/disenchant
Disenchant
2025-10-13 10:40:20 +09:00
Marcin Grzybowski
d82122de24 Update couchdb-init.sh
Fixed the {"error":"nodedown","reason":"nonode@nohost is down"} issue
2025-10-06 15:11:59 +02:00
63 changed files with 8630 additions and 3911 deletions

View File

@@ -0,0 +1,81 @@
---
title: "JWT Authentication on CouchDB"
livesync-version: 0.25.24
tags:
- tips
- CouchDB
- JWT
authors:
- vorotamoroz
---
# JWT Authentication on CouchDB
When using CouchDB as a backend for Self-hosted LiveSync, it is possible to enhance security by employing JWT (JSON Web Token) Authentication. In particular, using asymmetric keys (ES256 and ES512) provides greater security against token interception.
## Setting up JWT Authentication (Asymmetrical Key Example)
### 1. Generate a key pair
We can use `openssl` to generate an EC key pair as follows:
```bash
# Generate private key
# ES512 for secp521r1 curve, we can also use ES256 for prime256v1 curve
openssl ecparam -name secp521r1 -genkey -noout | openssl pkcs8 -topk8 -inform PEM -nocrypt -out private_key.pem
# openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -inform PEM -nocrypt -out private_key.pem
# Generate public key in SPKI format
openssl ec -in private_key.pem -pubout -outform PEM -out public_key.pem
```
> [!More tip]
> A key generator will be provided again in a future version of the user interface.
### 2. Configure CouchDB to accept JWT tokens
The following configuration is required:
| Key | Value | Note |
| ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| chttpd/authentication_handlers | {chttpd_auth, jwt_authentication_handler} | In total, it may be `{chttpd_auth, jwt_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}`, or something similar. |
| jwt_auth/required_claims | "exp" | |
| jwt_keys/ec:your_key_id | Your public key in PEM (SPKI) format | Replace `your_key_id` with your actual key ID. You can decide as you like. Note that you can add multiple keys if needed. If you want to use HSxxx, you should set `jwt_keys/hmac:your_key_id` with your HMAC secret. |
Note: When configuring CouchDB via web interface (Fauxton), new-lines on the public key should be replaced with `\n` for header and footer lines (So wired, but true I have tested). as follows:
```
-----BEGIN PUBLIC KEY-----
\nMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBq0irb/+K0Qzo7ayIHj0Xtthcntjz
r665J5UYdEQMiTtku5rnp95RuN97uA2pPOJOacMBAoiVUnZ1pqEBz9xH9yoAixji
Ju...........................................................gTt
/xtqrJRwrEy986oRZRQ=
\n-----END PUBLIC KEY-----
```
For detailed information, please refer to the [CouchDB JWT Authentication Documentation](https://docs.couchdb.org/en/stable/api/server/authn.html#jwt-authentication).
### 3. Configure Self-hosted LiveSync to use JWT Authentication
| Setting | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Use JWT Authentication | Enable this option to use JWT Authentication. |
| JWT Algorithm | Select the JWT signing algorithm (e.g., ES256, ES512) that matches your key pair. |
| JWT Key | Paste your private key in PEM (pkcs8) format. |
| JWT Expiration Duration | Set the token expiration time in minutes. Locally cached tokens are also invalidated after this duration. |
| JWT Key ID (kid) | Enter the key ID that you used when configuring CouchDB, i.e., the one that replaced `your_key_id`. |
| JWT Subject (sub) | Set your user ID; this overrides the original `Username` setting. If you have detected access with `Username`, you have failed to authorise with JWT. |
> [!IMPORTANT]
> Self-hosted LiveSync requests to CouchDB treat the user as `_admin`. If you want to restrict access, configure `jwt_auth/roles_claim_name` to a custom claim name. (Self-hosted LiveSync always sets `_couchdb.roles` with the value `["_admin"]`).
### 4. Test the configuration
Just try to `Test Settings and Continue` in the remote setup dialogue. If you have successfully authenticated, you are all set.
## Additional Notes
This feature is still experimental. Please ensure to test thoroughly in your environment before deploying to production.
However, we think that this is a great step towards enhancing security when using CouchDB with Self-hosted LiveSync. We shall enable this setting by default in future releases.
We would love to hear your feedback and any issues you encounter.

View File

@@ -0,0 +1,29 @@
---
title: "Peer-to-Peer Synchronisation Tips"
livesync-version: 0.25.24
tags:
- tips
- p2p
authors:
- vorotamoroz
---
# Peer-to-Peer Synchronisation Tips
> [!IMPORTANT]
> Peer-to-peer synchronisation is still an experimental feature. Although we have made every effort to ensure its reliability, it may not function correctly in all environments.
## Difficulties with Peer-to-Peer Synchronisation
It is often the case that peer-to-peer connections do not function correctly, for instance, when using mobile data services.
In such circumstances, we recommend connecting all devices to a single Virtual Private Network (VPN). It is advisable to select a service, such as Tailscale, which facilitates direct communication between peers wherever possible.
Should one be in an environment where even Tailscale is unable to connect, or where it cannot be lawfully installed, please continue reading.
## A More Detailed Explanation
The failure of a Peer-to-Peer connection via WebRTC can be attributed to several factors. These may include an unsuccessful UDP hole-punching attempt, or an intermediary gateway intentionally terminating the connection. Troubleshooting this matter is not a simple undertaking. Furthermore, and rather unfortunately, gateway administrators are typically aware of this type of network behaviour. Whilst a legitimate purpose for such traffic can be cited, such as for web conferencing, this is often insufficient to prevent it from being blocked.
This situation, however, is the primary reason that our project does not provide a TURN server. Although it is said that a TURN server within WebRTC does not decrypt communications, the project holds the view that the risk of a malicious party impersonating a TURN server must be avoided. Consequently, configuring a TURN server for relay communication is not currently possible through the user interface. Furthermore, there is no official project TURN server, which is to say, one that could be monitored by a third party.
We request that you provide your own server, using your own Fully Qualified Domain Name (FQDN), and subsequently enter its details into the advanced settings.
For testing purposes, Cloudflare's Real-Time TURN Service is exceedingly convenient and offers a generous amount of free data. However, it must be noted that because it is a well-known destination, such traffic is highly conspicuous. There is also a significant possibility that it may be blocked by default. We advise proceeding with caution.

View File

@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.21.beta2",
"version": "0.25.24.beta3",
"minAppVersion": "0.9.12",
"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",

View File

@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.21",
"version": "0.25.25",
"minAppVersion": "0.9.12",
"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",

6124
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.21",
"version": "0.25.25",
"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",
@@ -23,7 +23,7 @@
"pretty": "npm run prettyNoWrite -- --write --log-level error",
"prettyCheck": "npm run prettyNoWrite -- --check",
"prettyNoWrite": "prettier --config ./.prettierrc \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
"check": "npm run lint && npm run svelte-check && npm run tsc-check",
"check": "npm run lint && npm run svelte-check",
"unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/"
},
"keywords": [],
@@ -34,7 +34,9 @@
"@eslint/compat": "^1.2.7",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "^9.21.0",
"@tsconfig/svelte": "^5.0.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tsconfig/svelte": "^5.0.5",
"@types/deno": "^2.3.0",
"@types/diff-match-patch": "^1.0.36",
"@types/node": "^22.13.8",
"@types/pouchdb": "^6.4.2",
@@ -45,15 +47,15 @@
"@types/pouchdb-mapreduce": "^6.1.10",
"@types/pouchdb-replication": "^6.4.7",
"@types/transform-pouch": "^1.0.6",
"@typescript-eslint/eslint-plugin": "8.25.0",
"@typescript-eslint/parser": "8.25.0",
"@typescript-eslint/eslint-plugin": "8.46.2",
"@typescript-eslint/parser": "8.46.2",
"builtin-modules": "5.0.0",
"esbuild": "0.25.0",
"esbuild-plugin-inline-worker": "^0.1.1",
"esbuild-svelte": "^0.9.0",
"eslint": "^9.21.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-svelte": "^3.0.2",
"esbuild-svelte": "^0.9.3",
"eslint": "^9.38.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-svelte": "^3.12.4",
"events": "^3.3.0",
"glob": "^11.0.3",
"obsidian": "^1.8.7",
@@ -71,16 +73,15 @@
"pouchdb-replication": "^9.0.0",
"pouchdb-utils": "^9.0.0",
"prettier": "3.5.2",
"svelte": "5.28.6",
"svelte-check": "^4.1.7",
"svelte": "5.41.1",
"svelte-check": "^4.3.3",
"svelte-preprocess": "^6.0.3",
"terser": "^5.39.0",
"transform-pouch": "^2.0.0",
"tslib": "^2.8.1",
"tsx": "^4.19.4",
"typescript": "5.7.3",
"yaml": "^2.8.0",
"@types/deno": "^2.3.0"
"tsx": "^4.20.6",
"typescript": "5.9.3",
"yaml": "^2.8.0"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -93,9 +94,9 @@
"fflate": "^0.8.2",
"idb": "^8.0.3",
"minimatch": "^10.0.2",
"octagonal-wheels": "^0.1.41",
"octagonal-wheels": "^0.1.42",
"qrcode-generator": "^1.4.4",
"trystero": "github:vrtmrz/trystero#9e892a93ec14eeb57ce806d272fbb7c3935256d8",
"trystero": "^0.22.0",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
}
}

View File

@@ -21,6 +21,7 @@ export const EVENT_REQUEST_CLOSE_P2P = "request-close-p2p";
export const EVENT_REQUEST_RUN_DOCTOR = "request-run-doctor";
export const EVENT_REQUEST_RUN_FIX_INCOMPLETE = "request-run-fix-incomplete";
export const EVENT_ON_UNRESOLVED_ERROR = "on-unresolved-error";
// export const EVENT_FILE_CHANGED = "file-changed";
@@ -40,6 +41,7 @@ declare global {
[EVENT_REQUEST_SHOW_SETUP_QR]: undefined;
[EVENT_REQUEST_RUN_DOCTOR]: string;
[EVENT_REQUEST_RUN_FIX_INCOMPLETE]: undefined;
[EVENT_ON_UNRESOLVED_ERROR]: undefined;
}
}

View File

@@ -1741,7 +1741,7 @@ export class ConfigSync extends LiveSyncCommands {
return Promise.resolve(true);
}
private async _anyConfigureOptionalSyncFeature(mode: keyof OPTIONAL_SYNC_FEATURES) {
private async _allConfigureOptionalSyncFeature(mode: keyof OPTIONAL_SYNC_FEATURES) {
await this.configureHiddenFileSync(mode);
return true;
}
@@ -1813,6 +1813,6 @@ export class ConfigSync extends LiveSyncCommands {
services.databaseEvents.handleDatabaseInitialised(this._everyOnDatabaseInitialized.bind(this));
services.setting.handleSuspendExtraSync(this._allSuspendExtraSync.bind(this));
services.setting.handleSuggestOptionalFeatures(this._allAskUsingOptionalSyncFeature.bind(this));
services.setting.handleEnableOptionalFeature(this._anyConfigureOptionalSyncFeature.bind(this));
services.setting.handleEnableOptionalFeature(this._allConfigureOptionalSyncFeature.bind(this));
}
}

View File

@@ -1,4 +1,4 @@
import { normalizePath, type PluginManifest, type ListedFiles } from "../../deps.ts";
import { type PluginManifest, type ListedFiles } from "../../deps.ts";
import {
type LoadedEntry,
type FilePathWithPrefix,
@@ -10,7 +10,6 @@ import {
MODE_PAUSED,
type SavingEntry,
type DocumentID,
type FilePathWithPrefixLC,
type UXFileInfo,
type UXStat,
LOG_LEVEL_DEBUG,
@@ -177,24 +176,10 @@ export class HiddenFileSync extends LiveSyncCommands {
this.updateSettingCache();
return Promise.resolve(true);
}
updateSettingCache() {
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
this.ignorePatterns = ignorePatterns;
const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
this.targetPatterns = targetFilter;
this.shouldSkipFile = [] as FilePathWithPrefixLC[];
// Exclude files handled by customization sync
const configDir = normalizePath(this.app.vault.configDir);
const shouldSKip = !this.settings.usePluginSync
? []
: Object.values(this.settings.pluginSyncExtendedSetting)
.filter((e) => e.mode == MODE_SELECTIVE || e.mode == MODE_PAUSED)
.map((e) => e.files)
.flat()
.map((e) => `${configDir}/${e}`.toLowerCase());
this.shouldSkipFile = shouldSKip as FilePathWithPrefixLC[];
this._log(`Hidden file will skip ${this.shouldSkipFile.length} files`, LOG_LEVEL_INFO);
updateSettingCache() {
this.cacheCustomisationSyncIgnoredFiles.clear();
this.cacheFileRegExps.clear();
}
isReady() {
@@ -203,7 +188,6 @@ export class HiddenFileSync extends LiveSyncCommands {
if (!this.isThisModuleEnabled()) return false;
return true;
}
shouldSkipFile = [] as FilePathWithPrefixLC[];
async performStartupScan(showNotice: boolean) {
await this.applyOfflineChanges(showNotice);
@@ -232,10 +216,11 @@ export class HiddenFileSync extends LiveSyncCommands {
? this.settings.syncInternalFilesInterval * 1000
: 0
);
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
this.ignorePatterns = ignorePatterns;
const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
this.targetPatterns = targetFilter;
this.cacheFileRegExps.clear();
// const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
// this.ignorePatterns = ignorePatterns;
// const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
// this.targetPatterns = targetFilter;
return Promise.resolve(true);
}
@@ -558,8 +543,11 @@ Offline Changed files: ${processFiles.length}`;
forceWrite = false,
includeDeleted = true
): Promise<boolean | undefined> {
if (this.shouldSkipFile.some((e) => e.startsWith(path.toLowerCase()))) {
this._log(`Hidden file skipped: ${path} is synchronized in customization sync.`, LOG_LEVEL_VERBOSE);
if (!(await this.isTargetFile(path))) {
this._log(
`Storage file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
LOG_LEVEL_VERBOSE
);
return false;
}
try {
@@ -862,6 +850,108 @@ Offline Changed files: ${processFiles.length}`;
// --> Database Event Functions
cacheFileRegExps = new Map<string, CustomRegExp[][]>();
/**
* Parses the regular expression settings for hidden file synchronization.
* @returns An object containing the ignore and target filters.
*/
parseRegExpSettings() {
const regExpKey = `${this.plugin.settings.syncInternalFilesTargetPatterns}||${this.plugin.settings.syncInternalFilesIgnorePatterns}`;
let ignoreFilter: CustomRegExp[];
let targetFilter: CustomRegExp[];
if (this.cacheFileRegExps.has(regExpKey)) {
const cached = this.cacheFileRegExps.get(regExpKey)!;
ignoreFilter = cached[1];
targetFilter = cached[0];
} else {
ignoreFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
this.cacheFileRegExps.clear();
this.cacheFileRegExps.set(regExpKey, [targetFilter, ignoreFilter]);
}
return { ignoreFilter, targetFilter };
}
/**
* Checks if the target file path matches the defined patterns.
*/
isTargetFileInPatterns(path: string): boolean {
const { ignoreFilter, targetFilter } = this.parseRegExpSettings();
if (ignoreFilter && ignoreFilter.length > 0) {
for (const pattern of ignoreFilter) {
if (pattern.test(path)) {
return false;
}
}
}
if (targetFilter && targetFilter.length > 0) {
for (const pattern of targetFilter) {
if (pattern.test(path)) {
return true;
}
}
// While having target patterns, it effects as an allow-list.
return false;
}
return true;
}
cacheCustomisationSyncIgnoredFiles = new Map<string, string[]>();
/**
* Gets the list of files ignored for customization synchronization.
* @returns An array of ignored file paths (lowercase).
*/
getCustomisationSynchronizationIgnoredFiles(): string[] {
const configDir = this.plugin.app.vault.configDir;
const key =
JSON.stringify(this.settings.pluginSyncExtendedSetting) + `||${this.settings.usePluginSync}||${configDir}`;
if (this.cacheCustomisationSyncIgnoredFiles.has(key)) {
return this.cacheCustomisationSyncIgnoredFiles.get(key)!;
}
this.cacheCustomisationSyncIgnoredFiles.clear();
const synchronisedInConfigSync = !this.settings.usePluginSync
? []
: Object.values(this.settings.pluginSyncExtendedSetting)
.filter((e) => e.mode == MODE_SELECTIVE || e.mode == MODE_PAUSED)
.map((e) => e.files)
.flat()
.map((e) => `${configDir}/${e}`.toLowerCase());
this.cacheCustomisationSyncIgnoredFiles.set(key, synchronisedInConfigSync);
return synchronisedInConfigSync;
}
/**
* Checks if the given path is not ignored by customization synchronization.
* @param path The file path to check.
* @returns True if the path is not ignored; otherwise, false.
*/
isNotIgnoredByCustomisationSync(path: string): boolean {
const ignoredFiles = this.getCustomisationSynchronizationIgnoredFiles();
const result = !ignoredFiles.some((e) => path.startsWith(e));
// console.warn(`Assertion: isNotIgnoredByCustomisationSync(${path}) = ${result}`);
return result;
}
isHiddenFileSyncHandlingPath(path: FilePath): boolean {
const result = path.startsWith(".") && !path.startsWith(".trash");
// console.warn(`Assertion: isHiddenFileSyncHandlingPath(${path}) = ${result}`);
return result;
}
async isTargetFile(path: FilePath): Promise<boolean> {
const result =
this.isTargetFileInPatterns(path) &&
this.isNotIgnoredByCustomisationSync(path) &&
this.isHiddenFileSyncHandlingPath(path);
// console.warn(`Assertion: isTargetFile(${path}) : ${result ? "✔️" : "❌"}`);
if (!result) {
return false;
}
const resultByFile = await this.services.vault.isIgnoredByIgnoreFile(path);
// console.warn(`${path} -> isIgnoredByIgnoreFile: ${resultByFile ? "❌" : "✔️"}`);
return !resultByFile;
}
async trackScannedDatabaseChange(
processFiles: MetaEntry[],
showNotice: boolean = false,
@@ -875,14 +965,21 @@ Offline Changed files: ${processFiles.length}`;
const processes = processFiles.map(async (file) => {
try {
const path = stripAllPrefixes(this.getPath(file));
await this.trackDatabaseFileModification(
path,
"[Hidden file scan]",
!forceWriteAll,
onlyNew,
file,
includeDeletion
);
if (!(await this.isTargetFile(path))) {
this._log(
`Database file tracking: Hidden file skipped: ${path} is filtered out by the defined patterns.`,
LOG_LEVEL_VERBOSE
);
} else {
await this.trackDatabaseFileModification(
path,
"[Hidden file scan]",
!forceWriteAll,
onlyNew,
file,
includeDeletion
);
}
notifyProgress();
} catch (ex) {
this._log(`Failed to process storage change file:${file}`, logLevel);
@@ -1215,7 +1312,13 @@ Offline Changed files: ${files.length}`;
).rows
.filter((e) => isInternalMetadata(e.id as DocumentID))
.map((e) => e.doc) as MetaEntry[];
return allFiles;
const files = [] as MetaEntry[];
for (const file of allFiles) {
if (await this.isTargetFile(stripAllPrefixes(this.getPath(file)))) {
files.push(file);
}
}
return files;
}
async rebuildFromDatabase(showNotice: boolean, targetFiles: FilePath[] | false = false, onlyNew = false) {
@@ -1657,7 +1760,7 @@ ${messageFetch}${messageOverwrite}${messageMerge}
}
// --> Configuration handling
private async _anyConfigureOptionalSyncFeature(mode: keyof OPTIONAL_SYNC_FEATURES) {
private async _allConfigureOptionalSyncFeature(mode: keyof OPTIONAL_SYNC_FEATURES) {
await this.configureHiddenFileSync(mode);
return true;
}
@@ -1696,29 +1799,13 @@ ${messageFetch}${messageOverwrite}${messageMerge}
// <-- Configuration handling
// --> Local Storage SubFunctions
ignorePatterns: CustomRegExp[] = [];
targetPatterns: CustomRegExp[] = [];
async scanInternalFileNames() {
const configDir = normalizePath(this.app.vault.configDir);
const ignoreFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
const targetFilter = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
const synchronisedInConfigSync = !this.settings.usePluginSync
? []
: Object.values(this.settings.pluginSyncExtendedSetting)
.filter((e) => e.mode == MODE_SELECTIVE || e.mode == MODE_PAUSED)
.map((e) => e.files)
.flat()
.map((e) => `${configDir}/${e}`.toLowerCase());
const root = this.app.vault.getRoot();
const findRoot = root.path;
const filenames = (await this.getFiles(findRoot, [], targetFilter, ignoreFilter))
.filter((e) => e.startsWith("."))
.filter((e) => !e.startsWith(".trash"));
const files = filenames.filter((path) =>
synchronisedInConfigSync.every((filterFile) => !path.toLowerCase().startsWith(filterFile))
);
return files as FilePath[];
const filenames = await this.getFiles(findRoot, (path) => this.isTargetFile(path));
return filenames as FilePath[];
}
async scanInternalFiles(): Promise<InternalFileInfo[]> {
@@ -1748,7 +1835,32 @@ ${messageFetch}${messageOverwrite}${messageMerge}
return result;
}
async getFiles(path: string, ignoreList: string[], filter?: CustomRegExp[], ignoreFilter?: CustomRegExp[]) {
async getFiles(path: string, checkFunction: (path: FilePath) => Promise<boolean> | boolean) {
let w: ListedFiles;
try {
w = await this.app.vault.adapter.list(path);
} catch (ex) {
this._log(`Could not traverse(HiddenSync):${path}`, LOG_LEVEL_INFO);
this._log(ex, LOG_LEVEL_VERBOSE);
return [];
}
let files = [] as string[];
for (const file of w.files) {
if (!(await checkFunction(file as FilePath))) {
continue;
}
files.push(file);
}
for (const v of w.folders) {
if (!(await checkFunction(v as FilePath))) {
continue;
}
files = files.concat(await this.getFiles(v, checkFunction));
}
return files;
}
/*
async getFiles_(path: string, ignoreList: string[], filter?: CustomRegExp[], ignoreFilter?: CustomRegExp[]) {
let w: ListedFiles;
try {
w = await this.app.vault.adapter.list(path);
@@ -1785,11 +1897,11 @@ ${messageFetch}${messageOverwrite}${messageMerge}
if (await this.services.vault.isIgnoredByIgnoreFile(v)) {
continue L1;
}
files = files.concat(await this.getFiles(v, ignoreList, filter, ignoreFilter));
files = files.concat(await this.getFiles_(v, ignoreList, filter, ignoreFilter));
}
return files;
}
*/
// <-- Local Storage SubFunctions
onBindFunction(core: LiveSyncCore, services: typeof core.services) {
@@ -1805,6 +1917,6 @@ ${messageFetch}${messageOverwrite}${messageMerge}
services.databaseEvents.handleDatabaseInitialised(this._everyOnDatabaseInitialized.bind(this));
services.setting.handleSuspendExtraSync(this._allSuspendExtraSync.bind(this));
services.setting.handleSuggestOptionalFeatures(this._allAskUsingOptionalSyncFeature.bind(this));
services.setting.handleEnableOptionalFeature(this._anyConfigureOptionalSyncFeature.bind(this));
services.setting.handleEnableOptionalFeature(this._allConfigureOptionalSyncFeature.bind(this));
}
}

View File

@@ -32,6 +32,7 @@ import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { getPlatformName } from "../../lib/src/PlatformAPIs/obsidian/Environment.ts";
import type { LiveSyncCore } from "../../main.ts";
import { TrysteroReplicator } from "../../lib/src/replication/trystero/TrysteroReplicator.ts";
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../lib/src/common/types.ts";
export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase, CommandShim {
storeP2PStatusLine = reactiveSource("");
@@ -93,14 +94,10 @@ export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase
}
getConfig(key: string) {
const vaultName = this.services.vault.getVaultName();
const dbKey = `${vaultName}-${key}`;
return localStorage.getItem(dbKey);
return this.services.config.getSmallConfig(key);
}
setConfig(key: string, value: string) {
const vaultName = this.services.vault.getVaultName();
const dbKey = `${vaultName}-${key}`;
localStorage.setItem(dbKey, value);
return this.services.config.setSmallConfig(key, value);
}
enableBroadcastCastings() {
return this?._replicatorInstance?.enableBroadcastChanges();
@@ -125,7 +122,8 @@ export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase
if (!this.settings.P2P_AppID) {
this.settings.P2P_AppID = P2P_DEFAULT_SETTINGS.P2P_AppID;
}
const getInitialDeviceName = () => this.getConfig("p2p_device_name") || this.services.vault.getVaultName();
const getInitialDeviceName = () =>
this.getConfig(SETTING_KEY_P2P_DEVICE_NAME) || this.services.vault.getVaultName();
const getSettings = () => this.settings;
const store = () => this.simpleStore();

View File

@@ -19,6 +19,7 @@
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer";
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator";
import { $msg as _msg } from "../../../lib/src/common/i18n";
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types";
interface Props {
plugin: PluginShim;
@@ -35,7 +36,7 @@
// const vaultName = service.vault.getVaultName();
// const dbKey = `${vaultName}-p2p-device-name`;
const initialDeviceName = cmdSync.getConfig("p2p_device_name") ?? plugin.services.vault.getVaultName();
const initialDeviceName = cmdSync.getConfig(SETTING_KEY_P2P_DEVICE_NAME) ?? plugin.services.vault.getVaultName();
let deviceName = $state<string>(initialDeviceName);
let eP2PEnabled = $state<boolean>(initialSettings.P2P_Enabled);
@@ -84,7 +85,7 @@
P2P_AutoBroadcast: eAutoBroadcast,
};
plugin.settings = newSettings;
cmdSync.setConfig("p2p_device_name", eDeviceName);
cmdSync.setConfig(SETTING_KEY_P2P_DEVICE_NAME, eDeviceName);
deviceName = eDeviceName;
await plugin.saveSettings();
}
@@ -250,6 +251,9 @@
};
cmdSync.setConfig(initialDialogStatusKey, JSON.stringify(dialogStatus));
});
let isObsidian = $derived.by(() => {
return plugin.services.API.getPlatform() === "obsidian";
});
</script>
<article>
@@ -265,95 +269,105 @@
{/each}
</details>
<h2>Connection Settings</h2>
<details bind:open={isSettingOpened}>
<summary>{eRelay}</summary>
<table class="settings">
<tbody>
<tr>
<th> Enable P2P Replicator </th>
<td>
<label class={{ "is-dirty": isP2PEnabledModified }}>
<input type="checkbox" bind:checked={eP2PEnabled} />
</label>
</td>
</tr><tr>
<th> Relay settings </th>
<td>
<label class={{ "is-dirty": isRelayModified }}>
<input
type="text"
placeholder="wss://exp-relay.vrtmrz.net, wss://xxxxx"
bind:value={eRelay}
autocomplete="off"
/>
<button onclick={() => useDefaultRelay()}> Use vrtmrz's relay </button>
</label>
</td>
</tr>
<tr>
<th> Room ID </th>
<td>
<label class={{ "is-dirty": isRoomIdModified }}>
<input
type="text"
placeholder="anything-you-like"
bind:value={eRoomId}
autocomplete="off"
spellcheck="false"
autocorrect="off"
/>
<button onclick={() => chooseRandom()}> Use Random Number </button>
</label>
<span>
<small>
This can isolate your connections between devices. Use the same Room ID for the same
devices.</small
>
</span>
</td>
</tr>
<tr>
<th> Password </th>
<td>
<label class={{ "is-dirty": isPasswordModified }}>
<input type="password" placeholder="password" bind:value={ePassword} />
</label>
<span>
<small> This password is used to encrypt the connection. Use something long enough. </small>
</span>
</td>
</tr>
<tr>
<th> This device name </th>
<td>
<label class={{ "is-dirty": isDeviceNameModified }}>
<input type="text" placeholder="iphone-16" bind:value={eDeviceName} autocomplete="off" />
</label>
<span>
<small>
Device name to identify the device. Please use shorter one for the stable peer
detection, i.e., "iphone-16" or "macbook-2021".
</small>
</span>
</td>
</tr>
<tr>
<th> Auto Connect </th>
<td>
<label class={{ "is-dirty": isAutoStartModified }}>
<input type="checkbox" bind:checked={eAutoStart} />
</label>
</td>
</tr>
<tr>
<th> Start change-broadcasting on Connect </th>
<td>
<label class={{ "is-dirty": isAutoBroadcastModified }}>
<input type="checkbox" bind:checked={eAutoBroadcast} />
</label>
</td>
</tr>
<!-- <tr>
{#if isObsidian}
You can configure in the Obsidian Plugin Settings.
{:else}
<details bind:open={isSettingOpened}>
<summary>{eRelay}</summary>
<table class="settings">
<tbody>
<tr>
<th> Enable P2P Replicator </th>
<td>
<label class={{ "is-dirty": isP2PEnabledModified }}>
<input type="checkbox" bind:checked={eP2PEnabled} />
</label>
</td>
</tr><tr>
<th> Relay settings </th>
<td>
<label class={{ "is-dirty": isRelayModified }}>
<input
type="text"
placeholder="wss://exp-relay.vrtmrz.net, wss://xxxxx"
bind:value={eRelay}
autocomplete="off"
/>
<button onclick={() => useDefaultRelay()}> Use vrtmrz's relay </button>
</label>
</td>
</tr>
<tr>
<th> Room ID </th>
<td>
<label class={{ "is-dirty": isRoomIdModified }}>
<input
type="text"
placeholder="anything-you-like"
bind:value={eRoomId}
autocomplete="off"
spellcheck="false"
autocorrect="off"
/>
<button onclick={() => chooseRandom()}> Use Random Number </button>
</label>
<span>
<small>
This can isolate your connections between devices. Use the same Room ID for the same
devices.</small
>
</span>
</td>
</tr>
<tr>
<th> Password </th>
<td>
<label class={{ "is-dirty": isPasswordModified }}>
<input type="password" placeholder="password" bind:value={ePassword} />
</label>
<span>
<small>
This password is used to encrypt the connection. Use something long enough.
</small>
</span>
</td>
</tr>
<tr>
<th> This device name </th>
<td>
<label class={{ "is-dirty": isDeviceNameModified }}>
<input
type="text"
placeholder="iphone-16"
bind:value={eDeviceName}
autocomplete="off"
/>
</label>
<span>
<small>
Device name to identify the device. Please use shorter one for the stable peer
detection, i.e., "iphone-16" or "macbook-2021".
</small>
</span>
</td>
</tr>
<tr>
<th> Auto Connect </th>
<td>
<label class={{ "is-dirty": isAutoStartModified }}>
<input type="checkbox" bind:checked={eAutoStart} />
</label>
</td>
</tr>
<tr>
<th> Start change-broadcasting on Connect </th>
<td>
<label class={{ "is-dirty": isAutoBroadcastModified }}>
<input type="checkbox" bind:checked={eAutoBroadcast} />
</label>
</td>
</tr>
<!-- <tr>
<th> Auto Accepting </th>
<td>
<label class={{ "is-dirty": isAutoAcceptModified }}>
@@ -361,11 +375,12 @@
</label>
</td>
</tr> -->
</tbody>
</table>
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}>Save and Apply</button>
<button disabled={!isAnyModified} class="button" onclick={revert}>Revert changes</button>
</details>
</tbody>
</table>
<button disabled={!isAnyModified} class="button mod-cta" onclick={saveAndApply}>Save and Apply</button>
<button disabled={!isAnyModified} class="button" onclick={revert}>Revert changes</button>
</details>
{/if}
<div>
<h2>Signaling Server Connection</h2>

Submodule src/lib updated: 422194fb1b...6617500bc5

View File

@@ -34,6 +34,7 @@ import { ModuleObsidianSettings } from "./modules/features/ModuleObsidianSetting
import { ModuleRedFlag } from "./modules/coreFeatures/ModuleRedFlag.ts";
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
import { ModuleSetupObsidian } from "./modules/features/ModuleSetupObsidian.ts";
import { SetupManager } from "./modules/features/SetupManager.ts";
import type { StorageAccess } from "./modules/interfaces/StorageAccess.ts";
import type { Confirm } from "./lib/src/interfaces/Confirm.ts";
import type { Rebuilder } from "./modules/interfaces/DatabaseRebuilder.ts";
@@ -107,7 +108,7 @@ export default class ObsidianLiveSyncPlugin
/**
* The service hub for managing all services.
*/
_services: InjectableServiceHub = new ObsidianServiceHub();
_services: InjectableServiceHub = new ObsidianServiceHub(this);
get services() {
return this._services;
}
@@ -176,7 +177,15 @@ export default class ObsidianLiveSyncPlugin
new ModuleDev(this, this),
new ModuleReplicateTest(this, this),
new ModuleIntegratedTest(this, this),
new SetupManager(this, this),
] as (IObsidianModule | AbstractModule)[];
getModule<T extends IObsidianModule>(constructor: new (...args: any[]) => T): T {
for (const module of this.modules) {
if (module.constructor === constructor) return module as T;
}
throw new Error(`Module ${constructor} not found or not loaded.`);
}
// injected = injectModules(this, [...this.modules, ...this.addOns] as ICoreModule[]);
// <-- Module System

View File

@@ -353,6 +353,10 @@ export class ModuleFileHandler extends AbstractModule {
this._log(`File ${entry.path} is not the target file`, LOG_LEVEL_VERBOSE);
return false;
}
if (this.services.vault.isFileSizeTooLarge(entry.size)) {
this._log(`File ${entry.path} is too large (on database) to be processed`, LOG_LEVEL_VERBOSE);
return false;
}
if (shouldBeIgnored(entry.path)) {
this._log(`File ${entry.path} should be ignored`, LOG_LEVEL_VERBOSE);
return false;
@@ -365,6 +369,10 @@ export class ModuleFileHandler extends AbstractModule {
// Nothing to do and other modules should also nothing to do.
return true;
} else {
if (targetFile && this.services.vault.isFileSizeTooLarge(targetFile.stat.size)) {
this._log(`File ${targetFile.path} is too large (on storage) to be processed`, LOG_LEVEL_VERBOSE);
return false;
}
this._log(
`Processing ${path} (${entry._id.substring(0, 8)} :${entry._rev?.substring(0, 5)}) : Started...`,
LOG_LEVEL_VERBOSE
@@ -399,6 +407,10 @@ export class ModuleFileHandler extends AbstractModule {
incProcessed();
return true;
}
if (this.services.vault.isFileSizeTooLarge(file.stat.size)) {
incProcessed();
return true;
}
if (shouldBeIgnored(file.path)) {
incProcessed();
return true;

View File

@@ -36,6 +36,14 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
}
}
async informOptionalFeatures() {
await this.core.services.UI.showMarkdownDialog(
"All optional features are disabled",
`Customisation Sync and Hidden File Sync will all be disabled.
Please enable them from the settings screen after setup is complete.`,
["OK"]
);
}
async askUsingOptionalFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
if (
(await this.core.confirm.askYesNoDialog(
@@ -51,16 +59,17 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
await this.services.setting.suspendExtraSync();
this.core.settings.isConfigured = true;
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
await this.services.remote.markLocked();
await this.services.remote.tryResetDatabase();
await this.services.remote.markLocked();
await delay(500);
await this.askUsingOptionalFeature({ enableOverwrite: true });
// await this.askUsingOptionalFeature({ enableOverwrite: true });
await delay(1000);
await this.services.remote.replicateAllToRemote(true);
await delay(1000);
await this.services.remote.replicateAllToRemote(true, true);
await this.informOptionalFeatures();
}
$rebuildRemote(): Promise<void> {
return this.rebuildRemote();
@@ -70,7 +79,7 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
await this.services.setting.suspendExtraSync();
await this.askUseNewAdapter();
this.core.settings.isConfigured = true;
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
await this.resetLocalDatabase();
await delay(1000);
await this.services.databaseEvents.initialiseDatabase(true, true, true);
@@ -79,11 +88,12 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
await this.services.remote.markLocked();
await delay(500);
// We do not have any other devices' data, so we do not need to ask for overwriting.
await this.askUsingOptionalFeature({ enableOverwrite: false });
// await this.askUsingOptionalFeature({ enableOverwrite: false });
await delay(1000);
await this.services.remote.replicateAllToRemote(true);
await delay(1000);
await this.services.remote.replicateAllToRemote(true, true);
await this.informOptionalFeatures();
}
$rebuildEverything(): Promise<void> {
@@ -181,7 +191,7 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
await this.askUseNewAdapter();
this.core.settings.isConfigured = true;
await this.suspendReflectingDatabase();
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
await this.resetLocalDatabase();
await delay(1000);
await this.services.database.openDatabase();
@@ -200,7 +210,9 @@ export class ModuleRebuilder extends AbstractModule implements Rebuilder {
await delay(1000);
await this.services.remote.replicateAllFromRemote(true);
await this.resumeReflectingDatabase();
await this.askUsingOptionalFeature({ enableFetch: true });
await this.informOptionalFeatures();
// No longer enable
// await this.askUsingOptionalFeature({ enableFetch: true });
}
async fetchLocalWithRebuild() {
return await this.fetchLocal(true);

View File

@@ -1,7 +1,15 @@
import { fireAndForget, yieldMicrotask } from "octagonal-wheels/promises";
import type { LiveSyncLocalDB } from "../../lib/src/pouchdb/LiveSyncLocalDB";
import { AbstractModule } from "../AbstractModule";
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import {
Logger,
LOG_LEVEL_NOTICE,
LOG_LEVEL_INFO,
LOG_LEVEL_VERBOSE,
LEVEL_NOTICE,
LEVEL_INFO,
type LOG_LEVEL,
} from "octagonal-wheels/common/logger";
import { isLockAcquired, shareRunningResult, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
import { balanceChunkPurgedDBs } from "@/lib/src/pouchdb/chunks";
import { purgeUnreferencedChunks } from "@/lib/src/pouchdb/chunks";
@@ -28,7 +36,7 @@ import {
updatePreviousExecutionTime,
} from "../../common/utils";
import { isAnyNote } from "../../lib/src/common/utils";
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "../../common/events";
import { EVENT_FILE_SAVED, EVENT_ON_UNRESOLVED_ERROR, EVENT_SETTING_SAVED, eventHub } from "../../common/events";
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
import { $msg } from "../../lib/src/common/i18n";
@@ -40,6 +48,20 @@ const REPLICATION_ON_EVENT_FORECASTED_TIME = 5000;
export class ModuleReplicator extends AbstractModule {
_replicatorType?: RemoteType;
_previousErrors = new Set<string>();
showError(msg: string, max_log_level: LOG_LEVEL = LEVEL_NOTICE) {
const level = this._previousErrors.has(msg) ? LEVEL_INFO : max_log_level;
this._log(msg, level);
if (!this._previousErrors.has(msg)) {
this._previousErrors.add(msg);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
}
clearErrors() {
this._previousErrors.clear();
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
eventHub.onEvent(EVENT_FILE_SAVED, () => {
@@ -59,7 +81,7 @@ export class ModuleReplicator extends AbstractModule {
async setReplicator() {
const replicator = await this.services.replicator.getNewReplicator();
if (!replicator) {
this._log($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
this.showError($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
return false;
}
if (this.core.replicator) {
@@ -89,7 +111,7 @@ export class ModuleReplicator extends AbstractModule {
// Checking salt
const replicator = this.services.replicator.getActiveReplicator();
if (!replicator) {
this._log($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
this.showError($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
return false;
}
return await replicator.ensurePBKDF2Salt(this.settings, showMessage, true);
@@ -98,15 +120,16 @@ export class ModuleReplicator extends AbstractModule {
async _everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
// Checking salt
if (!this.core.managers.networkManager.isOnline) {
this._log("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
this.showError("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
return false;
}
// Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it).
if (!(await this.ensureReplicatorPBKDF2Salt(false))) {
Logger("Failed to initialise the encryption key, preventing replication.", LOG_LEVEL_NOTICE);
this.showError("Failed to initialise the encryption key, preventing replication.");
return false;
}
await this.loadQueuedFiles();
this.clearErrors();
return true;
}
@@ -195,18 +218,19 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
}
if (!(await this.services.fileProcessing.commitPendingFileEvents())) {
Logger($msg("Replicator.Message.Pending"), LOG_LEVEL_NOTICE);
this.showError($msg("Replicator.Message.Pending"), LOG_LEVEL_NOTICE);
return false;
}
if (!this.core.managers.networkManager.isOnline) {
this._log("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
this.showError("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
return false;
}
if (!(await this.services.replication.onBeforeReplicate(showMessage))) {
Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
this.showError($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
return false;
}
this.clearErrors();
return true;
}
@@ -401,11 +425,56 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
this.saveQueuedFiles();
});
async checkIsChangeRequiredForDatabaseProcessing(dbDoc: LoadedEntry): Promise<boolean> {
const path = getPath(dbDoc);
try {
const savedDoc = await this.localDatabase.getRaw<LoadedEntry>(dbDoc._id, {
conflicts: true,
revs_info: true,
});
const newRev = dbDoc._rev ?? "";
const latestRev = savedDoc._rev ?? "";
const revisions = savedDoc._revs_info?.map((e) => e.rev) ?? [];
if (savedDoc._conflicts && savedDoc._conflicts.length > 0) {
// There are conflicts, so we have to process it.
return true;
}
if (newRev == latestRev) {
// The latest revision. We need to process it.
return true;
}
const index = revisions.indexOf(newRev);
if (index >= 0) {
// the revision has been inserted before.
return false; // Already processed.
}
return true; // This mostly should not happen, but we have to process it just in case.
} catch (e: any) {
if ("status" in e && e.status == 404) {
return true;
// Not existing, so we have to process it.
} else {
Logger(
`Failed to get existing document for ${path} (${dbDoc._id.substring(0, 8)}, ${dbDoc._rev?.substring(0, 10)}) `,
LOG_LEVEL_NOTICE
);
Logger(e, LOG_LEVEL_VERBOSE);
return true;
}
}
return true;
}
databaseQueuedProcessor = new QueueProcessor(
async (docs: EntryBody[]) => {
const dbDoc = docs[0] as LoadedEntry; // It has no `data`
const path = getPath(dbDoc);
// If the document is existing with any revision, confirm that we have to process it.
const isRequired = await this.checkIsChangeRequiredForDatabaseProcessing(dbDoc);
if (!isRequired) {
Logger(`Skipped (Not latest): ${path} (${dbDoc._id.substring(0, 8)})`, LOG_LEVEL_VERBOSE);
return;
}
// If `Read chunks online` is disabled, chunks should be transferred before here.
// However, in some cases, chunks are after that. So, if missing chunks exist, we have to wait for them.
const doc = await this.localDatabase.getDBEntryFromMeta({ ...dbDoc }, false, true);
@@ -503,6 +572,10 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
return !checkResult;
}
private _reportUnresolvedMessages(): Promise<string[]> {
return Promise.resolve([...this._previousErrors]);
}
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.replicator.handleGetActiveReplicator(this._getReplicator.bind(this));
services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
@@ -516,5 +589,6 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
services.replication.handleReplicateByEvent(this._replicateByEvent.bind(this));
services.remote.handleReplicateAllToRemote(this._replicateAllToServer.bind(this));
services.remote.handleReplicateAllFromRemote(this._replicateAllFromServer.bind(this));
services.appLifecycle.reportUnresolvedMessages(this._reportUnresolvedMessages.bind(this));
}
}

View File

@@ -15,13 +15,12 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
}
_everyAfterResumeProcess(): Promise<boolean> {
if (!this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
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;
// If enabled LiveSync or on start, open replication
if (LiveSyncEnabled || eventualOnStart) {
// And note that we do not open the conflict detection dialogue directly during this process.

View File

@@ -26,6 +26,7 @@ export class ModuleTargetFilter extends AbstractModule {
this.ignoreFiles = this.settings.ignoreFiles.split(",").map((e) => e.trim());
}
private _everyOnload(): Promise<boolean> {
this.reloadIgnoreFiles();
eventHub.onEvent(EVENT_SETTING_SAVED, (evt: ObsidianLiveSyncSettings) => {
this.reloadIgnoreFiles();
});
@@ -132,12 +133,19 @@ export class ModuleTargetFilter extends AbstractModule {
ignoreFiles = [] as string[];
async readIgnoreFile(path: string) {
try {
const file = await this.core.storageAccess.readFileText(path);
// this._log(`[ignore]Reading ignore file: ${path}`, LOG_LEVEL_VERBOSE);
if (!(await this.core.storageAccess.isExistsIncludeHidden(path))) {
this.ignoreFileCache.set(path, false);
// this._log(`[ignore]Ignore file not found: ${path}`, LOG_LEVEL_VERBOSE);
return false;
}
const file = await this.core.storageAccess.readHiddenFileText(path);
const gitignore = file.split(/\r?\n/g);
this.ignoreFileCache.set(path, gitignore);
this._log(`[ignore]Ignore file loaded: ${path}`, LOG_LEVEL_VERBOSE);
return gitignore;
} catch (ex) {
this._log(`Failed to read ignore file ${path}`);
this._log(`[ignore]Failed to read ignore file ${path}`);
this._log(ex, LOG_LEVEL_VERBOSE);
this.ignoreFileCache.set(path, false);
return false;

View File

@@ -1,16 +1,17 @@
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { normalizePath } from "../../deps.ts";
import {
FLAGMD_REDFLAG,
FLAGMD_REDFLAG2,
FLAGMD_REDFLAG2_HR,
FLAGMD_REDFLAG3,
FLAGMD_REDFLAG3_HR,
FlagFilesHumanReadable,
FlagFilesOriginal,
TweakValuesShouldMatchedTemplate,
type ObsidianLiveSyncSettings,
} from "../../lib/src/common/types.ts";
import { AbstractModule } from "../AbstractModule.ts";
import { $msg } from "../../lib/src/common/i18n.ts";
import type { LiveSyncCore } from "../../main.ts";
import { SvelteDialogManager } from "../features/SetupWizard/ObsidianSvelteDialog.ts";
import FetchEverything from "../features/SetupWizard/dialogs/FetchEverything.svelte";
import RebuildEverything from "../features/SetupWizard/dialogs/RebuildEverything.svelte";
import { extractObject } from "octagonal-wheels/object";
export class ModuleRedFlag extends AbstractModule {
async isFlagFileExist(path: string) {
@@ -33,165 +34,284 @@ export class ModuleRedFlag extends AbstractModule {
}
}
isRedFlagRaised = async () => await this.isFlagFileExist(FLAGMD_REDFLAG);
isRedFlag2Raised = async () =>
(await this.isFlagFileExist(FLAGMD_REDFLAG2)) || (await this.isFlagFileExist(FLAGMD_REDFLAG2_HR));
isRedFlag3Raised = async () =>
(await this.isFlagFileExist(FLAGMD_REDFLAG3)) || (await this.isFlagFileExist(FLAGMD_REDFLAG3_HR));
isSuspendFlagActive = async () => await this.isFlagFileExist(FlagFilesOriginal.SUSPEND_ALL);
isRebuildFlagActive = async () =>
(await this.isFlagFileExist(FlagFilesOriginal.REBUILD_ALL)) ||
(await this.isFlagFileExist(FlagFilesHumanReadable.REBUILD_ALL));
isFetchAllFlagActive = async () =>
(await this.isFlagFileExist(FlagFilesOriginal.FETCH_ALL)) ||
(await this.isFlagFileExist(FlagFilesHumanReadable.FETCH_ALL));
async deleteRedFlag2() {
await this.deleteFlagFile(FLAGMD_REDFLAG2);
await this.deleteFlagFile(FLAGMD_REDFLAG2_HR);
async cleanupRebuildFlag() {
await this.deleteFlagFile(FlagFilesOriginal.REBUILD_ALL);
await this.deleteFlagFile(FlagFilesHumanReadable.REBUILD_ALL);
}
async deleteRedFlag3() {
await this.deleteFlagFile(FLAGMD_REDFLAG3);
await this.deleteFlagFile(FLAGMD_REDFLAG3_HR);
async cleanupFetchAllFlag() {
await this.deleteFlagFile(FlagFilesOriginal.FETCH_ALL);
await this.deleteFlagFile(FlagFilesHumanReadable.FETCH_ALL);
}
dialogManager = new SvelteDialogManager(this.core);
/**
* Adjust setting to remote if needed.
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
* @param config current configuration to retrieve remote preferred config
*/
async adjustSettingToRemoteIfNeeded(extra: { preventFetchingConfig: boolean }, config: ObsidianLiveSyncSettings) {
if (extra && extra.preventFetchingConfig) {
return;
}
// Remote configuration fetched and applied.
if (await this.adjustSettingToRemote(config)) {
config = this.core.settings;
} else {
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
}
console.debug(config);
}
/**
* Adjust setting to remote configuration.
* @param config current configuration to retrieve remote preferred config
* @returns updated configuration if applied, otherwise null.
*/
async adjustSettingToRemote(config: ObsidianLiveSyncSettings) {
// Fetch remote configuration unless prevented.
const SKIP_FETCH = "Skip and proceed";
const RETRY_FETCH = "Retry (recommended)";
let canProceed = false;
do {
const remoteTweaks = await this.services.tweakValue.fetchRemotePreferred(config);
if (!remoteTweaks) {
const choice = await this.core.confirm.askSelectStringDialogue(
"Could not fetch remote configuration. What do you want to do?",
[SKIP_FETCH, RETRY_FETCH] as const,
{
defaultAction: RETRY_FETCH,
timeout: 0,
title: "Fetch Remote Configuration Failed",
}
);
if (choice === SKIP_FETCH) {
canProceed = true;
}
} else {
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
// Check if any necessary tweak value is different from current config.
const differentItems = Object.entries(necessary).filter(([key, value]) => {
return (config as any)[key] !== value;
});
if (differentItems.length === 0) {
this._log(
"Remote configuration matches local configuration. No changes applied.",
LOG_LEVEL_NOTICE
);
} else {
await this.core.confirm.askSelectStringDialogue(
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
["OK"] as const,
{
defaultAction: "OK",
timeout: 0,
}
);
}
config = {
...config,
...Object.fromEntries(differentItems),
} satisfies ObsidianLiveSyncSettings;
this.core.settings = config;
await this.core.services.setting.saveSettingData();
this._log("Remote configuration applied.", LOG_LEVEL_NOTICE);
canProceed = true;
return this.core.settings;
}
} while (!canProceed);
}
/**
* Process vault initialisation with suspending file watching and sync.
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
* @param keepSuspending whether to keep suspending file watching after the process.
* @returns result of the process, or false if error occurs.
*/
async processVaultInitialisation(proc: () => Promise<boolean>, keepSuspending = false) {
try {
// Disable batch saving and file watching during initialisation.
this.settings.batchSave = false;
await this.services.setting.suspendAllSync();
await this.services.setting.suspendExtraSync();
this.settings.suspendFileWatching = true;
await this.saveSettings();
try {
const result = await proc();
return result;
} catch (ex) {
this._log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
} catch (ex) {
this._log("Error during vault initialisation.", LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
} finally {
if (!keepSuspending) {
// Re-enable file watching after initialisation.
this.settings.suspendFileWatching = false;
await this.saveSettings();
}
}
}
/**
* Handle the rebuild everything scheduled operation.
* @returns true if can be continued, false if app restart is needed.
*/
async onRebuildEverythingScheduled() {
const method = await this.dialogManager.openWithExplicitCancel(RebuildEverything);
if (method === "cancelled") {
// Clean up the flag file and restart the app.
this._log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
await this.cleanupRebuildFlag();
this.services.appLifecycle.performRestart();
return false;
}
const { extra } = method;
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
return await this.processVaultInitialisation(async () => {
await this.core.rebuilder.$rebuildEverything();
await this.cleanupRebuildFlag();
this._log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE);
return true;
});
}
/**
* Handle the fetch all scheduled operation.
* @returns true if can be continued, false if app restart is needed.
*/
async onFetchAllScheduled() {
const method = await this.dialogManager.openWithExplicitCancel(FetchEverything);
if (method === "cancelled") {
this._log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
// Clean up the flag file and restart the app.
await this.cleanupFetchAllFlag();
this.services.appLifecycle.performRestart();
return false;
}
const { vault, extra } = method;
const mapVaultStateToAction = {
identical: {
// If both are identical, no need to make local files/chunks before sync,
// Just for the efficiency, chunks should be made before sync.
makeLocalChunkBeforeSync: true,
makeLocalFilesBeforeSync: false,
},
independent: {
// If both are independent, nothing needs to be made before sync.
// Respect the remote state.
makeLocalChunkBeforeSync: false,
makeLocalFilesBeforeSync: false,
},
unbalanced: {
// If both are unbalanced, local files should be made before sync to avoid data loss.
// Then, chunks should be made before sync for the efficiency, but also the metadata made and should be detected as conflicting.
makeLocalChunkBeforeSync: false,
makeLocalFilesBeforeSync: true,
},
cancelled: {
// Cancelled case, not actually used.
makeLocalChunkBeforeSync: false,
makeLocalFilesBeforeSync: false,
},
} as const;
return await this.processVaultInitialisation(async () => {
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
// Okay, proceed to fetch everything.
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = mapVaultStateToAction[vault];
this._log(
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
LOG_LEVEL_INFO
);
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
await this.cleanupFetchAllFlag();
this._log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
return true;
});
}
async onSuspendAllScheduled() {
this._log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE);
return await this.processVaultInitialisation(async () => {
this._log(
"All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.",
LOG_LEVEL_NOTICE
);
this.settings.writeLogToTheFile = true;
await this.core.services.setting.saveSettingData();
return Promise.resolve(false);
}, true);
}
async verifyAndUnlockSuspension() {
if (!this.settings.suspendFileWatching) {
return true;
}
if (
(await this.core.confirm.askYesNoDialog(
"Do you want to resume file and database processing, and restart obsidian now?",
{ defaultOption: "Yes", timeout: 15 }
)) != "yes"
) {
// TODO: Confirm actually proceed to next process.
return true;
}
this.settings.suspendFileWatching = false;
await this.saveSettings();
this.services.appLifecycle.performRestart();
return false;
}
private async processFlagFilesOnStartup(): Promise<boolean> {
const isFlagSuspensionActive = await this.isSuspendFlagActive();
const isFlagRebuildActive = await this.isRebuildFlagActive();
const isFlagFetchAllActive = await this.isFetchAllFlagActive();
// TODO: Address the case when both flags are active (very unlikely though).
// if(isFlagFetchAllActive && isFlagRebuildActive) {
// const message = "Rebuild everything and Fetch everything flags are both detected.";
// await this.core.confirm.askSelectStringDialogue(
// "Both Rebuild Everything and Fetch Everything flags are detected. Please remove one of them and restart the app.",
// ["OK"] as const,)
if (isFlagFetchAllActive) {
const res = await this.onFetchAllScheduled();
if (res) {
return await this.verifyAndUnlockSuspension();
}
return false;
}
if (isFlagRebuildActive) {
const res = await this.onRebuildEverythingScheduled();
if (res) {
return await this.verifyAndUnlockSuspension();
}
return false;
}
if (isFlagSuspensionActive) {
const res = await this.onSuspendAllScheduled();
return res;
}
return true;
}
async _everyOnLayoutReady(): Promise<boolean> {
try {
const isRedFlagRaised = await this.isRedFlagRaised();
const isRedFlag2Raised = await this.isRedFlag2Raised();
const isRedFlag3Raised = await this.isRedFlag3Raised();
if (isRedFlagRaised || isRedFlag2Raised || isRedFlag3Raised) {
if (isRedFlag2Raised) {
if (
(await this.core.confirm.askYesNoDialog(
"Rebuild everything has been scheduled! Are you sure to rebuild everything?",
{ defaultOption: "Yes", timeout: 0 }
)) !== "yes"
) {
await this.deleteRedFlag2();
this.services.appLifecycle.performRestart();
return false;
}
}
if (isRedFlag3Raised) {
if (
(await this.core.confirm.askYesNoDialog("Fetch again has been scheduled! Are you sure?", {
defaultOption: "Yes",
timeout: 0,
})) !== "yes"
) {
await this.deleteRedFlag3();
this.services.appLifecycle.performRestart();
return false;
}
}
this.settings.batchSave = false;
await this.services.setting.suspendAllSync();
await this.services.setting.suspendExtraSync();
this.settings.suspendFileWatching = true;
await this.saveSettings();
if (isRedFlag2Raised) {
this._log(
`${FLAGMD_REDFLAG2} or ${FLAGMD_REDFLAG2_HR} has been detected! Self-hosted LiveSync suspends all sync and rebuild everything.`,
LOG_LEVEL_NOTICE
);
await this.core.rebuilder.$rebuildEverything();
await this.deleteRedFlag2();
if (
(await this.core.confirm.askYesNoDialog(
"Do you want to resume file and database processing, and restart obsidian now?",
{ defaultOption: "Yes", timeout: 15 }
)) == "yes"
) {
this.settings.suspendFileWatching = false;
await this.saveSettings();
this.services.appLifecycle.performRestart();
return false;
}
} else if (isRedFlag3Raised) {
this._log(
`${FLAGMD_REDFLAG3} or ${FLAGMD_REDFLAG3_HR} has been detected! Self-hosted LiveSync will discard the local database and fetch everything from the remote once again.`,
LOG_LEVEL_NOTICE
);
const method1 = $msg("RedFlag.Fetch.Method.FetchSafer");
const method2 = $msg("RedFlag.Fetch.Method.FetchSmoother");
const method3 = $msg("RedFlag.Fetch.Method.FetchTraditional");
const methods = [method1, method2, method3] as const;
const chunkMode = await this.core.confirm.askSelectStringDialogue(
$msg("RedFlag.Fetch.Method.Desc"),
methods,
{
defaultAction: method1,
timeout: 0,
title: $msg("RedFlag.Fetch.Method.Title"),
}
);
let makeLocalChunkBeforeSync = false;
let makeLocalFilesBeforeSync = false;
if (chunkMode === method1) {
makeLocalFilesBeforeSync = true;
} else if (chunkMode === method2) {
makeLocalChunkBeforeSync = true;
} else if (chunkMode === method3) {
// Do nothing.
} else {
this._log("Cancelled the fetch operation", LOG_LEVEL_NOTICE);
return false;
}
const optionFetchRemoteConf = $msg("RedFlag.FetchRemoteConfig.Buttons.Fetch");
const optionCancel = $msg("RedFlag.FetchRemoteConfig.Buttons.Cancel");
const fetchRemote = await this.core.confirm.askSelectStringDialogue(
$msg("RedFlag.FetchRemoteConfig.Message"),
[optionFetchRemoteConf, optionCancel],
{
defaultAction: optionFetchRemoteConf,
timeout: 0,
title: $msg("RedFlag.FetchRemoteConfig.Title"),
}
);
if (fetchRemote === optionFetchRemoteConf) {
this._log("Fetching remote configuration", LOG_LEVEL_NOTICE);
const newSettings = JSON.parse(JSON.stringify(this.core.settings)) as ObsidianLiveSyncSettings;
const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
if (remoteConfig) {
this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
const mergedSettings = {
...this.core.settings,
...remoteConfig,
} satisfies ObsidianLiveSyncSettings;
this._log("Remote configuration applied.", LOG_LEVEL_NOTICE);
this.core.settings = mergedSettings;
} else {
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
}
}
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
await this.deleteRedFlag3();
if (this.settings.suspendFileWatching) {
if (
(await this.core.confirm.askYesNoDialog(
"Do you want to resume file and database processing, and restart obsidian now?",
{ defaultOption: "Yes", timeout: 15 }
)) == "yes"
) {
this.settings.suspendFileWatching = false;
await this.saveSettings();
this.services.appLifecycle.performRestart();
return false;
}
} else {
this._log(
"Your content of files will be synchronised gradually. Please wait for the completion.",
LOG_LEVEL_NOTICE
);
}
} else {
// Case of FLAGMD_REDFLAG.
this.settings.writeLogToTheFile = true;
// await this.plugin.openDatabase();
const warningMessage =
"The red flag is raised! The whole initialize steps are skipped, and any file changes are not captured.";
this._log(warningMessage, LOG_LEVEL_NOTICE);
}
}
const flagProcessResult = await this.processFlagFilesOnStartup();
return flagProcessResult;
} catch (ex) {
this._log("Something went wrong on FlagFile Handling", LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);

View File

@@ -13,7 +13,7 @@ import {
type UXFileInfoStub,
type UXInternalFileInfoStub,
} from "../../../lib/src/common/types.ts";
import { delay, fireAndForget, getFileRegExp } from "../../../lib/src/common/utils.ts";
import { delay, fireAndForget } from "../../../lib/src/common/utils.ts";
import { type FileEventItem } from "../../../common/types.ts";
import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
import {
@@ -27,6 +27,7 @@ import type { LiveSyncCore } from "../../../main.ts";
import { InternalFileToUXFileInfoStub, TFileToUXFileInfoStub } from "./utilObsidian.ts";
import ObsidianLiveSyncPlugin from "../../../main.ts";
import type { StorageAccess } from "../../interfaces/StorageAccess.ts";
import { HiddenFileSync } from "../../../features/HiddenFileSync/CmdHiddenFileSync.ts";
// import { InternalFileToUXFileInfo } from "../platforms/obsidian.ts";
export type FileEvent = {
@@ -62,11 +63,15 @@ export class StorageEventManagerObsidian extends StorageEventManager {
get batchSaveMaximumDelay(): number {
return this.core.settings?.batchSaveMaximumDelay ?? DEFAULT_SETTINGS.batchSaveMaximumDelay;
}
// Necessary evil.
cmdHiddenFileSync: HiddenFileSync;
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, storageAccess: StorageAccess) {
super();
this.storageAccess = storageAccess;
this.plugin = plugin;
this.core = core;
this.cmdHiddenFileSync = this.plugin.getAddOn(HiddenFileSync.name) as HiddenFileSync;
}
beginWatch() {
const plugin = this.plugin;
@@ -181,22 +186,20 @@ export class StorageEventManagerObsidian extends StorageEventManager {
// (Calling$$isTargetFile will refresh the cache)
void this.services.vault.isTargetFile(path).then(() => this._watchVaultRawEvents(path));
} else {
this._watchVaultRawEvents(path);
void this._watchVaultRawEvents(path);
}
}
_watchVaultRawEvents(path: FilePath) {
async _watchVaultRawEvents(path: FilePath) {
if (!this.plugin.settings.syncInternalFiles && !this.plugin.settings.usePluginSync) return;
if (!this.plugin.settings.watchInternalFileChanges) return;
if (!path.startsWith(this.plugin.app.vault.configDir)) return;
const ignorePatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesIgnorePatterns");
const targetPatterns = getFileRegExp(this.plugin.settings, "syncInternalFilesTargetPatterns");
if (ignorePatterns.some((e) => e.test(path))) return;
if (!targetPatterns.some((e) => e.test(path))) return;
if (path.endsWith("/")) {
// Folder
return;
}
const isTargetFile = await this.cmdHiddenFileSync.isTargetFile(path);
if (!isTargetFile) return;
void this.appendQueue(
[

View File

@@ -3,7 +3,6 @@ import {
EVENT_REQUEST_OPEN_P2P,
EVENT_REQUEST_OPEN_SETTING_WIZARD,
EVENT_REQUEST_OPEN_SETTINGS,
EVENT_REQUEST_OPEN_SETUP_URI,
EVENT_REQUEST_RUN_DOCTOR,
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
eventHub,
@@ -16,6 +15,7 @@ import { isMetaEntry } from "../../lib/src/common/types.ts";
import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "../../lib/src/common/utils.ts";
import { countCompromisedChunks } from "../../lib/src/pouchdb/negotiation.ts";
import type { LiveSyncCore } from "../../main.ts";
import { SetupManager } from "../features/SetupManager.ts";
type ErrorInfo = {
path: string;
@@ -66,6 +66,9 @@ export class ModuleMigration extends AbstractModule {
}
async initialMessage() {
const manager = this.core.getModule(SetupManager);
return await manager.startOnBoarding();
/*
const message = $msg("moduleMigration.msgInitialSetup", {
URI_DOC: $msg("moduleMigration.docUri"),
});
@@ -83,6 +86,7 @@ export class ModuleMigration extends AbstractModule {
return true;
}
return false;
*/
}
async askAgainForSetupURI() {
@@ -326,8 +330,11 @@ export class ModuleMigration extends AbstractModule {
await this.migrateDisableBulkSend();
}
if (!this.settings.isConfigured) {
// Case sensitivity
if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
// if (!(await this.initialMessage()) || !(await this.askAgainForSetupURI())) {
// this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
// return false;
// }
if (!(await this.initialMessage())) {
this._log($msg("moduleMigration.logSetupCancelled"), LOG_LEVEL_NOTICE);
return false;
}

View File

@@ -1,5 +1,12 @@
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { LOG_LEVEL_DEBUG, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import {
LEVEL_INFO,
LEVEL_NOTICE,
LOG_LEVEL_DEBUG,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LOG_LEVEL,
} from "octagonal-wheels/common/logger";
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "../../deps.ts";
import { type CouchDBCredentials, type EntryDoc, type FilePath } from "../../lib/src/common/types.ts";
import { getPathFromTFile } from "../../common/utils.ts";
@@ -12,6 +19,7 @@ import { ObsHttpHandler } from "./APILib/ObsHttpHandler.ts";
import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.ts";
import { AuthorizationHeaderGenerator } from "../../lib/src/replication/httplib.ts";
import type { LiveSyncCore } from "../../main.ts";
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts";
setNoticeClass(Notice);
@@ -24,7 +32,20 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
_customHandler!: ObsHttpHandler;
_authHeader = new AuthorizationHeaderGenerator();
_previousErrors = new Set<string>();
showError(msg: string, max_log_level: LOG_LEVEL = LEVEL_NOTICE) {
const level = this._previousErrors.has(msg) ? LEVEL_INFO : max_log_level;
this._log(msg, level);
if (!this._previousErrors.has(msg)) {
this._previousErrors.add(msg);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
}
clearErrors() {
this._previousErrors.clear();
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
}
last_successful_post = false;
_customFetchHandler(): ObsHttpHandler {
if (!this._customHandler) this._customHandler = new ObsHttpHandler(undefined, undefined);
@@ -102,7 +123,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
compression: boolean,
customHeaders: Record<string, string>,
useRequestAPI: boolean,
getPBKDF2Salt: () => Promise<Uint8Array>
getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>
): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> {
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
if (uri.toLowerCase() != uri) return "Remote URI and database name could not contain capital letters.";
@@ -180,6 +201,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
}
}
}
this.clearErrors();
return response;
} catch (ex) {
if (ex instanceof TypeError) {
@@ -195,7 +217,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
headers,
});
if (resp2.status / 100 == 2) {
this._log(
this.showError(
"The request was successful by API. But the native fetch API failed! Please check CORS settings on the remote database!. While this condition, you cannot enable LiveSync",
LOG_LEVEL_NOTICE
);
@@ -203,7 +225,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
}
const r2 = resp2.clone();
const msg = await r2.text();
this._log(`Failed to fetch by API. ${resp2.status}: ${msg}`, LOG_LEVEL_NOTICE);
this.showError(`Failed to fetch by API. ${resp2.status}: ${msg}`, LOG_LEVEL_NOTICE);
return resp2;
}
throw ex;
@@ -211,7 +233,7 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
} catch (ex: any) {
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
const msg = ex instanceof Error ? `${ex?.name}:${ex?.message}` : ex?.toString();
this._log(`Failed to fetch: ${msg}`, LOG_LEVEL_NOTICE);
this.showError(`Failed to fetch: ${msg}`); // Do not show notice, due to throwing below
this._log(ex, LOG_LEVEL_VERBOSE);
// limit only in bulk_docs.
if (url.toString().indexOf("_bulk_docs") !== -1) {
@@ -279,6 +301,10 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
return `${"appId" in this.app ? this.app.appId : ""}`;
}
private _reportUnresolvedMessages(): Promise<string[]> {
return Promise.resolve([...this._previousErrors]);
}
onBindFunction(core: LiveSyncCore, services: typeof core.services) {
services.API.handleGetCustomFetchHandler(this._customFetchHandler.bind(this));
services.API.handleIsLastPostFailedDueToPayloadSize(this._getLastPostFailedBySize.bind(this));
@@ -288,5 +314,6 @@ export class ModuleObsidianAPI extends AbstractObsidianModule {
services.vault.handleVaultName(this._vaultName.bind(this));
services.vault.handleGetActiveFilePath(this._getActiveFilePath.bind(this));
services.API.handleGetAppID(this._anyGetAppId.bind(this));
services.appLifecycle.reportUnresolvedMessages(this._reportUnresolvedMessages.bind(this));
}
}

View File

@@ -59,7 +59,7 @@ export class ModuleObsidianMenu extends AbstractObsidianModule {
this.settings.liveSync = true;
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
}
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
await this.services.setting.saveSettingData();
},
});
@@ -74,7 +74,7 @@ export class ModuleObsidianMenu extends AbstractObsidianModule {
this.services.appLifecycle.setSuspended(true);
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
}
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
await this.services.setting.saveSettingData();
},
});

View File

@@ -85,17 +85,15 @@ export class ConflictResolveModal extends Modal {
}
}
diff = diff.replace(/\n/g, "<br>");
div.innerHTML = diff;
const div2 = contentEl.createDiv("");
const date1 =
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
const date2 =
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
div2.innerHTML = `
div2.setHTMLUnsafe(`
<span class='deleted'><span class='conflict-dev-name'>${this.localName}</span>: ${date1}</span><br>
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>
`;
`);
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
).style.marginRight = "4px";
@@ -110,6 +108,13 @@ export class ConflictResolveModal extends Modal {
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
e.addEventListener("click", () => this.sendResponse(CANCELLED))
).style.marginRight = "4px";
diff = diff.replace(/\n/g, "<br>");
// div.innerHTML = diff;
if (diff.length > 100 * 1024) {
div.setText("(Too large diff to display)");
} else {
div.setHTMLUnsafe(diff);
}
}
sendResponse(result: MergeDialogResult) {

View File

@@ -19,7 +19,12 @@ import {
logMessages,
} from "../../lib/src/mock_and_interop/stores.ts";
import { eventHub } from "../../lib/src/hub/hub.ts";
import { EVENT_FILE_RENAMED, EVENT_LAYOUT_READY, EVENT_LEAF_ACTIVE_CHANGED } from "../../common/events.ts";
import {
EVENT_FILE_RENAMED,
EVENT_LAYOUT_READY,
EVENT_LEAF_ACTIVE_CHANGED,
EVENT_ON_UNRESOLVED_ERROR,
} from "../../common/events.ts";
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { addIcon, normalizePath, Notice } from "../../deps.ts";
import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
@@ -198,11 +203,13 @@ export class ModuleLog extends AbstractObsidianModule {
this.applyStatusBarText();
}, 20);
statusBarLabels.onChanged((label) => applyToDisplay(label.value));
this.activeFileStatus.onChanged(() => this.updateMessageArea());
}
private _everyOnload(): Promise<boolean> {
eventHub.onEvent(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange());
eventHub.onceEvent(EVENT_LAYOUT_READY, () => this.onActiveLeafChange());
eventHub.onEvent(EVENT_ON_UNRESOLVED_ERROR, () => this.updateMessageArea());
return Promise.resolve(true);
}
@@ -234,8 +241,19 @@ export class ModuleLog extends AbstractObsidianModule {
async setFileStatus() {
const fileStatus = await this.getActiveFileStatus();
this.activeFileStatus.value = fileStatus;
this.messageArea!.innerText = this.settings.hideFileWarningNotice ? "" : fileStatus;
}
async updateMessageArea() {
if (this.messageArea) {
const messageLines = [];
const fileStatus = this.activeFileStatus.value;
if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus);
const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e);
messageLines.push(...messages);
this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n");
}
}
onActiveLeafChange() {
fireAndForget(async () => {
this.adjustStatusDivPosition();

View File

@@ -9,6 +9,7 @@ import {
DEFAULT_SETTINGS,
type ObsidianLiveSyncSettings,
SALT_OF_PASSPHRASE,
SETTING_KEY_P2P_DEVICE_NAME,
} from "../../lib/src/common/types";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
import { $msg, setLang } from "../../lib/src/common/i18n.ts";
@@ -111,6 +112,11 @@ export class ModuleObsidianSettings extends AbstractObsidianModule {
this.services.setting.saveDeviceAndVaultName();
const settings = { ...this.settings };
settings.deviceAndVaultName = "";
if (settings.P2P_DevicePeerName && settings.P2P_DevicePeerName.trim() !== "") {
console.log("Saving device peer name to small config");
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, settings.P2P_DevicePeerName.trim());
settings.P2P_DevicePeerName = "";
}
if (this.usedPassphrase == "" && !(await this.getPassphrase(settings))) {
this._log("Failed to retrieve passphrase. data.json contains unencrypted items!", LOG_LEVEL_NOTICE);
} else {

View File

@@ -1,34 +1,38 @@
import {
type ObsidianLiveSyncSettings,
DEFAULT_SETTINGS,
KeyIndexOfSettings,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
} from "../../lib/src/common/types.ts";
import { configURIBase, configURIBaseQR } from "../../common/types.ts";
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE } from "../../lib/src/common/types.ts";
import { configURIBase } from "../../common/types.ts";
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
import { fireAndForget } from "../../lib/src/common/utils.ts";
import {
EVENT_REQUEST_COPY_SETUP_URI,
EVENT_REQUEST_OPEN_P2P_SETTINGS,
EVENT_REQUEST_OPEN_SETUP_URI,
EVENT_REQUEST_SHOW_SETUP_QR,
eventHub,
} from "../../common/events.ts";
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { decodeAnyArray, encodeAnyArray } from "../../common/utils.ts";
import qrcode from "qrcode-generator";
import { $msg } from "../../lib/src/common/i18n.ts";
import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
import { encryptString, decryptString } from "@/lib/src/encryption/stringEncryption.ts";
// import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
import type { LiveSyncCore } from "../../main.ts";
import {
encodeQR,
encodeSettingsToQRCodeData,
encodeSettingsToSetupURI,
OutputFormat,
} from "../../lib/src/API/processSetting.ts";
import { SetupManager, UserMode } from "./SetupManager.ts";
export class ModuleSetupObsidian extends AbstractObsidianModule {
private _setupManager!: SetupManager;
private _everyOnload(): Promise<boolean> {
this._setupManager = this.plugin.getModule(SetupManager);
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
if (conf.settings) {
await this.setupWizard(conf.settings);
await this._setupManager.onUseSetupURI(
UserMode.Unknown,
`${configURIBase}${encodeURIComponent(conf.settings)}`
);
} else if (conf.settingsQR) {
await this.decodeQR(conf.settingsQR);
await this._setupManager.decodeQR(conf.settingsQR);
}
});
this.addCommand({
@@ -59,293 +63,134 @@ export class ModuleSetupObsidian extends AbstractObsidianModule {
name: "Use the copied setup URI (Formerly Open setup URI)",
callback: () => fireAndForget(this.command_openSetupURI()),
});
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () =>
fireAndForget(() => {
return this._setupManager.onP2PManualSetup(UserMode.Update, this.settings, false);
})
);
return Promise.resolve(true);
}
async encodeQR() {
const settingArr = [];
const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][];
for (const [settingKey, index] of fullIndexes) {
const settingValue = this.settings[settingKey];
if (index < 0) {
// This setting should be ignored.
continue;
}
settingArr[index] = settingValue;
}
const w = encodeAnyArray(settingArr);
const qr = qrcode(0, "L");
const uri = `${configURIBaseQR}${encodeURIComponent(w)}`;
qr.addData(uri);
qr.make();
const img = qr.createSvgTag(3);
const msg = $msg("Setup.QRCode", { qr_image: img });
const settingString = encodeSettingsToQRCodeData(this.settings);
const codeSVG = encodeQR(settingString, OutputFormat.SVG);
const msg = $msg("Setup.QRCode", { qr_image: codeSVG });
await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK");
return await Promise.resolve(w);
return await Promise.resolve(codeSVG);
}
async decodeQR(qr: string) {
const settingArr = decodeAnyArray(qr);
// console.warn(settingArr);
const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][];
const newSettings = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings;
for (const [settingKey, index] of fullIndexes) {
if (index < 0) {
// This setting should be ignored.
continue;
}
if (index >= settingArr.length) {
// Possibly a new setting added.
continue;
}
const settingValue = settingArr[index];
//@ts-ignore
newSettings[settingKey] = settingValue;
}
await this.applySettingWizard(this.settings, newSettings, "QR Code");
async askEncryptingPassphrase(): Promise<string | false> {
const encryptingPassphrase = await this.core.confirm.askString(
"Encrypt your settings",
"The passphrase to encrypt the setup URI",
"",
true
);
return encryptingPassphrase;
}
async command_copySetupURI(stripExtra = true) {
const encryptingPassphrase = await this.core.confirm.askString(
"Encrypt your settings",
"The passphrase to encrypt the setup URI",
"",
const encryptingPassphrase = await this.askEncryptingPassphrase();
if (encryptingPassphrase === false) return;
const encryptedURI = await encodeSettingsToSetupURI(
this.settings,
encryptingPassphrase,
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
true
);
if (encryptingPassphrase === false) return;
const setting = {
...this.settings,
configPassphraseStore: "",
encryptedCouchDBConnection: "",
encryptedPassphrase: "",
} as Partial<ObsidianLiveSyncSettings>;
if (stripExtra) {
delete setting.pluginSyncExtendedSetting;
if (await this.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
}
const keys = Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[];
for (const k of keys) {
if (
JSON.stringify(k in setting ? setting[k] : "") ==
JSON.stringify(k in DEFAULT_SETTINGS ? DEFAULT_SETTINGS[k] : "*")
) {
delete setting[k];
}
}
const encryptedSetting = encodeURIComponent(await encryptString(JSON.stringify(setting), encryptingPassphrase));
const uri = `${configURIBase}${encryptedSetting} `;
await navigator.clipboard.writeText(uri);
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
// await navigator.clipboard.writeText(encryptedURI);
}
async command_copySetupURIFull() {
const encryptingPassphrase = await this.core.confirm.askString(
"Encrypt your settings",
"The passphrase to encrypt the setup URI",
"",
true
);
const encryptingPassphrase = await this.askEncryptingPassphrase();
if (encryptingPassphrase === false) return;
const setting = {
...this.settings,
configPassphraseStore: "",
encryptedCouchDBConnection: "",
encryptedPassphrase: "",
};
const encryptedSetting = encodeURIComponent(await encryptString(JSON.stringify(setting), encryptingPassphrase));
const uri = `${configURIBase}${encryptedSetting} `;
await navigator.clipboard.writeText(uri);
const encryptedURI = await encodeSettingsToSetupURI(this.settings, encryptingPassphrase, [], false);
await navigator.clipboard.writeText(encryptedURI);
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
}
async command_copySetupURIWithSync() {
await this.command_copySetupURI(false);
}
async command_openSetupURI() {
const setupURI = await this.core.confirm.askString("Easy setup", "Set up URI", `${configURIBase} aaaaa`);
if (setupURI === false) return;
if (!setupURI.startsWith(`${configURIBase}`)) {
this._log("Set up URI looks wrong.", LOG_LEVEL_NOTICE);
return;
}
const config = decodeURIComponent(setupURI.substring(configURIBase.length));
await this.setupWizard(config);
}
async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
const buttons = {
fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
} as const;
const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
$msg("Setup.FetchRemoteConf.Message"),
Object.values(buttons),
{ defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
);
if (fetchRemoteConf == buttons.no) {
return tryingSettings;
}
const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
if (remoteConfig) {
this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
const resultSettings = {
...DEFAULT_SETTINGS,
...tryingSettings,
...remoteConfig,
} satisfies ObsidianLiveSyncSettings;
return resultSettings;
} else {
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
return {
...DEFAULT_SETTINGS,
...tryingSettings,
} satisfies ObsidianLiveSyncSettings;
}
}
async askPerformDoctor(
tryingSettings: ObsidianLiveSyncSettings
): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
const buttons = {
yes: $msg("Setup.Doctor.Buttons.Yes"),
no: $msg("Setup.Doctor.Buttons.No"),
} as const;
const performDoctor = await this.core.confirm.askSelectStringDialogue(
$msg("Setup.Doctor.Message"),
Object.values(buttons),
{ defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
);
if (performDoctor == buttons.no) {
return { settings: tryingSettings, shouldRebuild: false, isModified: false };
}
const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
remoteRebuild: RebuildOptions.SkipEvenIfRequired,
activateReason: "New settings from URI",
});
if (isModified) {
this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
return {
settings,
shouldRebuild,
isModified,
};
} else {
this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
return { settings: tryingSettings, shouldRebuild: false, isModified: false };
}
await this._setupManager.onUseSetupURI(UserMode.Unknown);
}
async applySettingWizard(
oldConf: ObsidianLiveSyncSettings,
newConf: ObsidianLiveSyncSettings,
method = "Setup URI"
) {
const result = await this.core.confirm.askYesNoDialog(
"Importing Configuration from the " + method + ". Are you sure to proceed ? ",
{}
);
if (result == "yes") {
let newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings;
this.core.replicator.closeReplication();
this.settings.suspendFileWatching = true;
newSettingW = await this.askSyncWithRemoteConfig(newSettingW);
const { settings, shouldRebuild, isModified } = await this.askPerformDoctor(newSettingW);
if (isModified) {
newSettingW = settings;
}
// Back into the default method once.
newSettingW.configPassphraseStore = "";
newSettingW.encryptedPassphrase = "";
newSettingW.encryptedCouchDBConnection = "";
newSettingW.additionalSuffixOfDatabaseName = `${"appId" in this.app ? this.app.appId : ""} `;
const setupJustImport = $msg("Setup.Apply.Buttons.OnlyApply");
const setupAsNew = $msg("Setup.Apply.Buttons.ApplyAndFetch");
const setupAsMerge = $msg("Setup.Apply.Buttons.ApplyAndMerge");
const setupAgain = $msg("Setup.Apply.Buttons.ApplyAndRebuild");
const setupCancel = $msg("Setup.Apply.Buttons.Cancel");
newSettingW.syncInternalFiles = false;
newSettingW.usePluginSync = false;
newSettingW.isConfigured = true;
// Migrate completely obsoleted configuration.
if (!newSettingW.useIndexedDBAdapter) {
newSettingW.useIndexedDBAdapter = true;
}
const warn = shouldRebuild ? $msg("Setup.Apply.WarningRebuildRecommended") : "";
const message = $msg("Setup.Apply.Message", {
method,
warn,
});
// TODO: Where to implement these?
// async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
// const buttons = {
// fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
// no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
// } as const;
// const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
// $msg("Setup.FetchRemoteConf.Message"),
// Object.values(buttons),
// { defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
// );
// if (fetchRemoteConf == buttons.no) {
// return tryingSettings;
// }
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
// const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
// if (remoteConfig) {
// this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
// const resultSettings = {
// ...DEFAULT_SETTINGS,
// ...tryingSettings,
// ...remoteConfig,
// } satisfies ObsidianLiveSyncSettings;
// return resultSettings;
// } else {
// this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
// return {
// ...DEFAULT_SETTINGS,
// ...tryingSettings,
// } satisfies ObsidianLiveSyncSettings;
// }
// }
// async askPerformDoctor(
// tryingSettings: ObsidianLiveSyncSettings
// ): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
// const buttons = {
// yes: $msg("Setup.Doctor.Buttons.Yes"),
// no: $msg("Setup.Doctor.Buttons.No"),
// } as const;
// const performDoctor = await this.core.confirm.askSelectStringDialogue(
// $msg("Setup.Doctor.Message"),
// Object.values(buttons),
// { defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
// );
// if (performDoctor == buttons.no) {
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
// }
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
// const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
// localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
// remoteRebuild: RebuildOptions.SkipEvenIfRequired,
// activateReason: "New settings from URI",
// });
// if (isModified) {
// this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
// return {
// settings,
// shouldRebuild,
// isModified,
// };
// } else {
// this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
// }
// }
const setupType = await this.core.confirm.askSelectStringDialogue(
message,
[setupAsNew, setupAsMerge, setupAgain, setupJustImport, setupCancel],
{ defaultAction: setupAsNew, title: $msg("Setup.Apply.Title", { method }), timeout: 0 }
);
if (setupType == setupJustImport) {
this.core.settings = newSettingW;
this.services.setting.clearUsedPassphrase();
await this.core.saveSettings();
} else if (setupType == setupAsNew) {
this.core.settings = newSettingW;
this.services.setting.clearUsedPassphrase();
await this.core.saveSettings();
await this.core.rebuilder.$fetchLocal();
} else if (setupType == setupAsMerge) {
this.core.settings = newSettingW;
this.services.setting.clearUsedPassphrase();
await this.core.saveSettings();
await this.core.rebuilder.$fetchLocal(true);
} else if (setupType == setupAgain) {
const confirm =
"This operation will rebuild all databases with files on this device. Any files on the remote database not synced here will be lost.";
if (
(await this.core.confirm.askSelectStringDialogue(
"Are you sure you want to do this?",
["Cancel", confirm],
{ defaultAction: "Cancel" }
)) != confirm
) {
return;
}
this.core.settings = newSettingW;
await this.core.saveSettings();
this.services.setting.clearUsedPassphrase();
await this.core.rebuilder.$rebuildEverything();
} else {
// Explicitly cancel the operation or the dialog was closed.
this._log("Cancelled", LOG_LEVEL_NOTICE);
this.core.settings = oldConf;
return;
}
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
} else {
this._log("Cancelled", LOG_LEVEL_NOTICE);
this.core.settings = oldConf;
return;
}
}
async setupWizard(confString: string) {
try {
const oldConf = JSON.parse(JSON.stringify(this.settings));
const encryptingPassphrase = await this.core.confirm.askString(
"Passphrase",
"The passphrase to decrypt your setup URI",
"",
true
);
if (encryptingPassphrase === false) return;
const newConf = await JSON.parse(await decryptString(confString, encryptingPassphrase));
if (newConf) {
await this.applySettingWizard(oldConf, newConf);
this._log("Configuration loaded.", LOG_LEVEL_NOTICE);
} else {
this._log("Cancelled.", LOG_LEVEL_NOTICE);
}
} catch (ex) {
this._log("Couldn't parse or decrypt configuration uri.", LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.handleOnLoaded(this._everyOnload.bind(this));
}

View File

@@ -0,0 +1,15 @@
<script lang="ts">
/**
* Info Panel to display key-value information from the port
* Mostly used in the Setting Dialogue
*/
import { type SveltePanelProps } from "./SveltePanel";
import InfoTable from "@lib/UI/components/InfoTable.svelte";
type Props = SveltePanelProps<{
info: Record<string, any>;
}>;
const { port }: Props = $props();
const info = $derived.by(() => $port?.info ?? {});
</script>
<InfoTable {info} />

View File

@@ -143,6 +143,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
pluginConfig.jwtKid = redact(pluginConfig.jwtKid);
pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders);
pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders);
pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential);
pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername);
pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`;
const endpoint = pluginConfig.endpoint;
if (endpoint == "") {
pluginConfig.endpoint = "Not configured or AWS";
@@ -170,11 +173,13 @@ ${stringifyYaml({
...pluginConfig,
})}`;
console.log(msgConfig);
await navigator.clipboard.writeText(msgConfig);
Logger(
`Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`,
LOG_LEVEL_NOTICE
);
if ((await this.services.UI.promptCopyToClipboard("Generated report", msgConfig)) == true) {
// await navigator.clipboard.writeText(msgConfig);
// Logger(
// `Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`,
// LOG_LEVEL_NOTICE
// );
}
})
);
new Setting(paneEl).autoWireToggle("writeLogToTheFile");

View File

@@ -1,6 +1,6 @@
import { LocalDatabaseMaintenance } from "../../../features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts";
import { LOG_LEVEL_NOTICE, Logger } from "../../../lib/src/common/logger.ts";
import { FLAGMD_REDFLAG, FLAGMD_REDFLAG2_HR, FLAGMD_REDFLAG3_HR } from "../../../lib/src/common/types.ts";
import { FlagFilesHumanReadable, FLAGMD_REDFLAG } from "../../../lib/src/common/types.ts";
import { fireAndForget } from "../../../lib/src/common/utils.ts";
import { LiveSyncCouchDBReplicator } from "../../../lib/src/replication/couchdb/LiveSyncReplicator.ts";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
@@ -98,6 +98,35 @@ export function paneMaintenance(
);
});
void addPanel(paneEl, "Reset Synchronisation information").then((paneEl) => {
new Setting(paneEl)
.setName("Reset Synchronisation on This Device")
.setDesc("Restore or reconstruct local database from remote.")
.addButton((button) =>
button
.setButtonText("Schedule and Restart")
.setCta()
.setDisabled(false)
.onClick(async () => {
await this.plugin.storageAccess.writeFileAuto(FlagFilesHumanReadable.FETCH_ALL, "");
this.services.appLifecycle.performRestart();
})
);
new Setting(paneEl)
.setName("Overwrite Server Data with This Device's Files")
.setDesc("Rebuild local and remote database with local files.")
.addButton((button) =>
button
.setButtonText("Schedule and Restart")
.setCta()
.setDisabled(false)
.onClick(async () => {
await this.plugin.storageAccess.writeFileAuto(FlagFilesHumanReadable.REBUILD_ALL, "");
this.services.appLifecycle.performRestart();
})
);
});
void addPanel(paneEl, "Syncing", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
new Setting(paneEl)
.setName("Resend")
@@ -244,69 +273,7 @@ export function paneMaintenance(
);
}
);
void addPanel(paneEl, "Rebuilding Operations (Local)").then((paneEl) => {
new Setting(paneEl)
.setName("Fetch from remote")
.setDesc("Restore or reconstruct local database from remote.")
.addButton((button) =>
button
.setButtonText("Fetch")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.plugin.storageAccess.writeFileAuto(FLAGMD_REDFLAG3_HR, "");
this.services.appLifecycle.performRestart();
})
)
.addButton((button) =>
button
.setButtonText("Fetch w/o restarting")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("localOnly");
})
);
new Setting(paneEl)
.setName("Fetch rebuilt DB (Save local documents before)")
.setDesc("Restore or reconstruct local database from remote database but use local chunks.")
.addButton((button) =>
button
.setButtonText("Save and Fetch")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("localOnlyWithChunks");
})
)
.addOnUpdate(this.onlyOnCouchDB);
});
void addPanel(paneEl, "Total Overhaul", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
new Setting(paneEl)
.setName("Rebuild everything")
.setDesc("Rebuild local and remote database with local files.")
.addButton((button) =>
button
.setButtonText("Rebuild")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.plugin.storageAccess.writeFileAuto(FLAGMD_REDFLAG2_HR, "");
this.services.appLifecycle.performRestart();
})
)
.addButton((button) =>
button
.setButtonText("Rebuild w/o restarting")
.setWarning()
.setDisabled(false)
.onClick(async () => {
await this.rebuildDB("rebuildBothByThisDevice");
})
);
});
void addPanel(paneEl, "Rebuilding Operations (Remote Only)", () => {}, this.onlyOnCouchDBOrMinIO).then((paneEl) => {
new Setting(paneEl)
.setName("Perform cleanup")

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import type { PageFunctions } from "./SettingPane.ts";
import { visibleOnly } from "./SettingPane.ts";
import { DEFAULT_SETTINGS } from "../../../lib/src/common/types.ts";
import { request } from "obsidian";
import { SetupManager, UserMode } from "../SetupManager.ts";
export function paneSetup(
this: ObsidianLiveSyncSettingTab,
paneEl: HTMLElement,
@@ -30,11 +31,13 @@ export function paneSetup(
});
new Setting(paneEl)
.setName($msg("obsidianLiveSyncSettingTab.nameManualSetup"))
.setDesc($msg("obsidianLiveSyncSettingTab.descManualSetup"))
.setName("Rerun Onboarding Wizard")
.setDesc("Rerun the onboarding wizard to set up Self-hosted LiveSync again.")
.addButton((text) => {
text.setButtonText($msg("obsidianLiveSyncSettingTab.btnStart")).onClick(async () => {
await this.enableMinimalSetup();
text.setButtonText("Rerun Wizard").onClick(async () => {
const setupManager = this.plugin.getModule(SetupManager);
await setupManager.onOnboard(UserMode.ExistingUser);
// await this.plugin.moduleSetupObsidian.onBoardingWizard(true);
});
});

View File

@@ -105,7 +105,7 @@ export function paneSyncSettings(
if (!this.editingSettings.isConfigured) {
this.editingSettings.isConfigured = true;
await this.saveAllDirtySettings();
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
await this.rebuildDB("localOnly");
// this.resetEditingSettings();
if (
@@ -124,13 +124,13 @@ export function paneSyncSettings(
await this.confirmRebuild();
} else {
await this.saveAllDirtySettings();
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
this.services.appLifecycle.askRestart();
}
}
} else {
await this.saveAllDirtySettings();
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
}
});
});
@@ -169,7 +169,7 @@ export function paneSyncSettings(
}
await this.saveSettings(["liveSync", "periodicReplication"]);
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
});
new Setting(paneEl)

View File

@@ -0,0 +1,54 @@
import { mount, type Component, unmount } from "svelte";
import { type Writable, writable, get } from "svelte/store";
/**
* Props passed to Svelte panels, containing a writable port
* to communicate with the panel
*/
export type SveltePanelProps<T = any> = {
port: Writable<T | undefined>;
};
/**
* A class to manage a Svelte panel within Obsidian
* Especially useful for settings panels
*/
export class SveltePanel<T = any> {
private _mountedComponent: ReturnType<typeof mount>;
private _componentValue = writable<T | undefined>(undefined);
/**
* Creates a Svelte panel instance
* @param component Component to mount
* @param mountTo HTMLElement to mount the component to
* @param valueStore Optional writable store to bind to the component's port, if not provided a new one will be created
* @returns The SveltePanel instance
*/
constructor(component: Component<SveltePanelProps<T>>, mountTo: HTMLElement, valueStore?: Writable<T>) {
this._componentValue = valueStore ?? writable<T | undefined>(undefined);
this._mountedComponent = mount(component, {
target: mountTo,
props: {
port: this._componentValue,
},
});
return this;
}
/**
* Destroys the Svelte panel instance by unmounting the component
*/
destroy() {
if (this._mountedComponent) {
void unmount(this._mountedComponent);
}
}
/**
* Gets or sets the current value of the component's port
*/
get componentValue() {
return get(this._componentValue);
}
set componentValue(value: T | undefined) {
this._componentValue.set(value);
}
}

View File

@@ -0,0 +1,78 @@
import { escapeStringToHTML } from "octagonal-wheels/string";
import { E2EEAlgorithmNames, type ObsidianLiveSyncSettings } from "../../../lib/src/common/types";
import {
pickCouchDBSyncSettings,
pickBucketSyncSettings,
pickP2PSyncSettings,
pickEncryptionSettings,
} from "../../../lib/src/common/utils";
import { getConfig, type AllSettingItemKey } from "./settingConstants";
/**
* Generates a summary of P2P configuration settings
* @param setting Settings object
* @param additional Additional summary information to include
* @param showAdvanced Whether to include advanced settings
* @returns Summary object
*/
export function getP2PConfigSummary(
setting: ObsidianLiveSyncSettings,
additional: Record<string, string> = {},
showAdvanced = false
) {
const settingTable: Partial<ObsidianLiveSyncSettings> = pickP2PSyncSettings(setting);
return { ...getSummaryFromPartialSettings({ ...settingTable }, showAdvanced), ...additional };
}
/**
* Generates a summary of Object Storage configuration settings
* @param setting Settings object
* @param showAdvanced Whether to include advanced settings
* @returns Summary object
*/
export function getBucketConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
const settingTable: Partial<ObsidianLiveSyncSettings> = pickBucketSyncSettings(setting);
return getSummaryFromPartialSettings(settingTable, showAdvanced);
}
/**
* Generates a summary of CouchDB configuration settings
* @param setting Settings object
* @param showAdvanced Whether to include advanced settings
* @returns Summary object
*/
export function getCouchDBConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
const settingTable: Partial<ObsidianLiveSyncSettings> = pickCouchDBSyncSettings(setting);
return getSummaryFromPartialSettings(settingTable, showAdvanced || setting.useJWT);
}
/**
* Generates a summary of E2EE configuration settings
* @param setting Settings object
* @param showAdvanced Whether to include advanced settings
* @returns Summary object
*/
export function getE2EEConfigSummary(setting: ObsidianLiveSyncSettings, showAdvanced = false) {
const settingTable: Partial<ObsidianLiveSyncSettings> = pickEncryptionSettings(setting);
return getSummaryFromPartialSettings(settingTable, showAdvanced);
}
/**
* Converts partial settings into a summary object
* @param setting Partial settings object
* @param showAdvanced Whether to include advanced settings
* @returns Summary object
*/
export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncSettings>, showAdvanced = false) {
const outputSummary: Record<string, string> = {};
for (const key of Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]) {
const config = getConfig(key as AllSettingItemKey);
if (!config) continue;
if (config.isAdvanced && !showAdvanced) continue;
const value =
key != "E2EEAlgorithm"
? `${setting[key]}`
: E2EEAlgorithmNames[`${setting[key]}` as keyof typeof E2EEAlgorithmNames];
const displayValue = config.isHidden ? "•".repeat(value.length) : escapeStringToHTML(value);
outputSummary[config.name] = displayValue;
}
return outputSummary;
}

View File

@@ -0,0 +1,274 @@
import { requestToCouchDBWithCredentials } from "../../../common/utils";
import { $msg } from "../../../lib/src/common/i18n";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "../../../lib/src/common/logger";
import type { ObsidianLiveSyncSettings } from "../../../lib/src/common/types";
import { fireAndForget, parseHeaderValues } from "../../../lib/src/common/utils";
import { isCloudantURI } from "../../../lib/src/pouchdb/utils_couchdb";
import { generateCredentialObject } from "../../../lib/src/replication/httplib";
export const checkConfig = async (
checkResultDiv: HTMLDivElement | undefined,
editingSettings: ObsidianLiveSyncSettings
) => {
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
let isSuccessful = true;
const emptyDiv = createDiv();
emptyDiv.innerHTML = "<span></span>";
checkResultDiv?.replaceChildren(...[emptyDiv]);
const addResult = (msg: string, classes?: string[]) => {
const tmpDiv = createDiv();
tmpDiv.addClass("ob-btn-config-fix");
if (classes) {
tmpDiv.addClasses(classes);
}
tmpDiv.innerHTML = `${msg}`;
checkResultDiv?.appendChild(tmpDiv);
};
try {
if (isCloudantURI(editingSettings.couchDB_URI)) {
Logger($msg("obsidianLiveSyncSettingTab.logCannotUseCloudant"), LOG_LEVEL_NOTICE);
return;
}
// Tip: Add log for cloudant as Logger($msg("obsidianLiveSyncSettingTab.logServerConfigurationCheck"));
const customHeaders = parseHeaderValues(editingSettings.couchDB_CustomHeaders);
const credential = generateCredentialObject(editingSettings);
const r = await requestToCouchDBWithCredentials(
editingSettings.couchDB_URI,
credential,
window.origin,
undefined,
undefined,
undefined,
customHeaders
);
const responseConfig = r.json;
const addConfigFixButton = (title: string, key: string, value: string) => {
if (!checkResultDiv) return;
const tmpDiv = createDiv();
tmpDiv.addClass("ob-btn-config-fix");
tmpDiv.innerHTML = `<label>${title}</label><button>${$msg("obsidianLiveSyncSettingTab.btnFix")}</button>`;
const x = checkResultDiv.appendChild(tmpDiv);
x.querySelector("button")?.addEventListener("click", () => {
fireAndForget(async () => {
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
const res = await requestToCouchDBWithCredentials(
editingSettings.couchDB_URI,
credential,
undefined,
key,
value,
undefined,
customHeaders
);
if (res.status == 200) {
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigUpdated", { title }), LOG_LEVEL_NOTICE);
checkResultDiv.removeChild(x);
await checkConfig(checkResultDiv, editingSettings);
} else {
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigFail", { title }), LOG_LEVEL_NOTICE);
Logger(res.text, LOG_LEVEL_VERBOSE);
}
});
});
};
addResult($msg("obsidianLiveSyncSettingTab.msgNotice"), ["ob-btn-config-head"]);
addResult($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]);
addResult($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]);
const serverBanner = r.headers["server"] ?? r.headers["Server"] ?? "unknown";
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
const versionParts = `${versionStr}.0.0.0`.split(".");
// Compare version string with the target version.
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
function isGreaterThanOrEqual(version: string) {
const targetParts = version.split(".");
for (let i = 0; i < targetParts.length; i++) {
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
if (result > 0) return true;
if (result < 0) return false;
}
return true;
}
// Admin check
// for database creation and deletion
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
addResult($msg("obsidianLiveSyncSettingTab.warnNoAdmin"));
} else {
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
}
if (isGreaterThanOrEqual("3.2.0")) {
// HTTP user-authorization check
if (responseConfig?.chttpd?.require_valid_user != "true") {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUser"));
addConfigFixButton(
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUser"),
"chttpd/require_valid_user",
"true"
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUser"));
}
} else {
if (responseConfig?.chttpd_auth?.require_valid_user != "true") {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errRequireValidUserAuth"));
addConfigFixButton(
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth"),
"chttpd_auth/require_valid_user",
"true"
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okRequireValidUserAuth"));
}
}
// HTTPD check
// Check Authentication header
if (!responseConfig?.httpd["WWW-Authenticate"]) {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errMissingWwwAuth"));
addConfigFixButton(
$msg("obsidianLiveSyncSettingTab.msgSetWwwAuth"),
"httpd/WWW-Authenticate",
'Basic realm="couchdb"'
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
}
if (isGreaterThanOrEqual("3.2.0")) {
if (responseConfig?.chttpd?.enable_cors != "true") {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
addConfigFixButton(
$msg("obsidianLiveSyncSettingTab.msgEnableCorsChttpd"),
"chttpd/enable_cors",
"true"
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okEnableCorsChttpd"));
}
} else {
if (responseConfig?.httpd?.enable_cors != "true") {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errEnableCors"));
addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgEnableCors"), "httpd/enable_cors", "true");
} else {
addResult($msg("obsidianLiveSyncSettingTab.okEnableCors"));
}
}
// If the server is not cloudant, configure request size
if (!isCloudantURI(editingSettings.couchDB_URI)) {
// REQUEST SIZE
if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errMaxRequestSize"));
addConfigFixButton(
$msg("obsidianLiveSyncSettingTab.msgSetMaxRequestSize"),
"chttpd/max_http_request_size",
"4294967296"
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okMaxRequestSize"));
}
if (Number(responseConfig?.couchdb?.max_document_size ?? 0) < 50000000) {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errMaxDocumentSize"));
addConfigFixButton(
$msg("obsidianLiveSyncSettingTab.msgSetMaxDocSize"),
"couchdb/max_document_size",
"50000000"
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okMaxDocumentSize"));
}
}
// CORS check
// checking connectivity for mobile
if (responseConfig?.cors?.credentials != "true") {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errCorsCredentials"));
addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgSetCorsCredentials"), "cors/credentials", "true");
} else {
addResult($msg("obsidianLiveSyncSettingTab.okCorsCredentials"));
}
const ConfiguredOrigins = ((responseConfig?.cors?.origins ?? "") + "").split(",");
if (
responseConfig?.cors?.origins == "*" ||
(ConfiguredOrigins.indexOf("app://obsidian.md") !== -1 &&
ConfiguredOrigins.indexOf("capacitor://localhost") !== -1 &&
ConfiguredOrigins.indexOf("http://localhost") !== -1)
) {
addResult($msg("obsidianLiveSyncSettingTab.okCorsOrigins"));
} else {
const fixedValue = [
...new Set([
...ConfiguredOrigins.map((e) => e.trim()),
"app://obsidian.md",
"capacitor://localhost",
"http://localhost",
]),
].join(",");
addResult($msg("obsidianLiveSyncSettingTab.errCorsOrigins"));
addConfigFixButton($msg("obsidianLiveSyncSettingTab.msgSetCorsOrigins"), "cors/origins", fixedValue);
isSuccessful = false;
}
addResult($msg("obsidianLiveSyncSettingTab.msgConnectionCheck"), ["ob-btn-config-head"]);
addResult($msg("obsidianLiveSyncSettingTab.msgCurrentOrigin", { origin: window.location.origin }));
// Request header check
const origins = ["app://obsidian.md", "capacitor://localhost", "http://localhost"];
for (const org of origins) {
const rr = await requestToCouchDBWithCredentials(
editingSettings.couchDB_URI,
credential,
org,
undefined,
undefined,
undefined,
customHeaders
);
const responseHeaders = Object.fromEntries(
Object.entries(rr.headers).map((e) => {
e[0] = `${e[0]}`.toLowerCase();
return e;
})
);
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
if (responseHeaders["access-control-allow-credentials"] != "true") {
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
isSuccessful = false;
} else {
addResult($msg("obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin"));
}
if (responseHeaders["access-control-allow-origin"] != org) {
addResult(
$msg("obsidianLiveSyncSettingTab.warnCorsOriginUnmatched", {
from: origin,
to: responseHeaders["access-control-allow-origin"],
})
);
} else {
addResult($msg("obsidianLiveSyncSettingTab.okCorsOriginMatched"));
}
}
addResult($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]);
addResult($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]);
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"), LOG_LEVEL_INFO);
} catch (ex: any) {
if (ex?.status == 401) {
isSuccessful = false;
addResult($msg("obsidianLiveSyncSettingTab.errAccessForbidden"));
addResult($msg("obsidianLiveSyncSettingTab.errCannotContinueTest"));
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"), LOG_LEVEL_INFO);
} else {
Logger($msg("obsidianLiveSyncSettingTab.logCheckingConfigFailed"), LOG_LEVEL_NOTICE);
Logger(ex);
isSuccessful = false;
}
}
return isSuccessful;
};

View File

@@ -0,0 +1,378 @@
import {
type ObsidianLiveSyncSettings,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "../../lib/src/common/types.ts";
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { SvelteDialogManager } from "./SetupWizard/ObsidianSvelteDialog.ts";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
import ScanQRCode from "./SetupWizard/dialogs/ScanQRCode.svelte";
import UseSetupURI from "./SetupWizard/dialogs/UseSetupURI.svelte";
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 "../../lib/src/API/processSetting.ts";
/**
* User modes for onboarding and setup
*/
export const enum UserMode {
/**
* New User Mode - for users who are new to the plugin
*/
NewUser = "new-user",
/**
* Existing User Mode - for users who have used the plugin before, or just configuring again
*/
ExistingUser = "existing-user",
/**
* Unknown User Mode - for cases where the user mode is not determined
*/
Unknown = "unknown",
/**
* Update User Mode - for users who are updating configuration. May be `existing-user` as well, but possibly they want to treat it differently.
*/
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
Update = "unknown", // Alias for Unknown for better readability
}
/**
* Setup Manager to handle onboarding and configuration setup
*/
export class SetupManager extends AbstractObsidianModule {
/**
* Dialog manager for handling Svelte dialogs
*/
private dialogManager: SvelteDialogManager = new SvelteDialogManager(this.plugin);
/**
* Starts the onboarding process
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
*/
async startOnBoarding(): Promise<boolean> {
const isUserNewOrExisting = await this.dialogManager.openWithExplicitCancel(Intro);
if (isUserNewOrExisting === "new-user") {
await this.onOnboard(UserMode.NewUser);
} else if (isUserNewOrExisting === "existing-user") {
await this.onOnboard(UserMode.ExistingUser);
} else if (isUserNewOrExisting === "cancelled") {
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
return false;
}
return false;
}
/**
* Handles the onboarding process based on user mode
* @param userMode
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
*/
async onOnboard(userMode: UserMode): Promise<boolean> {
const originalSetting = userMode === UserMode.NewUser ? DEFAULT_SETTINGS : this.core.settings;
if (userMode === UserMode.NewUser) {
//Ask how to apply initial setup
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodNewUser);
if (method === "use-setup-uri") {
await this.onUseSetupURI(userMode);
} else if (method === "configure-manually") {
await this.onConfigureManually(originalSetting, userMode);
} else if (method === "cancelled") {
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
return false;
}
} else if (userMode === UserMode.ExistingUser) {
const method = await this.dialogManager.openWithExplicitCancel(SelectMethodExisting);
if (method === "use-setup-uri") {
await this.onUseSetupURI(userMode);
} else if (method === "configure-manually") {
await this.onConfigureManually(originalSetting, userMode);
} else if (method === "scan-qr-code") {
await this.onPromptQRCodeInstruction();
} else if (method === "cancelled") {
this._log("Onboarding cancelled by user.", LOG_LEVEL_NOTICE);
return false;
}
}
return false;
}
/**
* Handles setup using a setup URI
* @param userMode
* @param setupURI
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
*/
async onUseSetupURI(userMode: UserMode, setupURI: string = ""): Promise<boolean> {
const newSetting = await this.dialogManager.openWithExplicitCancel(UseSetupURI, setupURI);
if (newSetting === "cancelled") {
this._log("Setup URI dialog cancelled.", LOG_LEVEL_NOTICE);
return false;
}
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
}
/**
* Handles manual setup for CouchDB
* @param userMode
* @param currentSetting
* @param activate Whether to activate the CouchDB as remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onCouchDBManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
const couchConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, originalSetting);
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...baseSetting, ...couchConf } as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for S3-compatible bucket
* @param userMode
* @param currentSetting
* @param activate Whether to activate the Bucket as remote type
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onBucketManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const bucketConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteBucket, currentSetting);
if (bucketConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...currentSetting, ...bucketConf } as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles manual setup for P2P
* @param userMode
* @param currentSetting
* @param activate Whether to activate the P2P as remote type (as P2P Only setup)
* @returns Promise that resolves to true if setup completed successfully, false otherwise
*/
async onP2PManualSetup(
userMode: UserMode,
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const p2pConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSetting);
if (p2pConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
if (activate) {
newSetting.remoteType = REMOTE_P2P;
}
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
/**
* Handles only E2EE configuration
* @param userMode
* @param currentSetting
* @returns
*/
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, currentSetting);
if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return await false;
}
const newSetting = {
...currentSetting,
...e2eeConf,
} as ObsidianLiveSyncSettings;
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
}
/**
* Handles manual configuration flow (E2EE + select server)
* @param originalSetting
* @param userMode
* @returns
*/
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, originalSetting);
if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const currentSetting = {
...originalSetting,
...e2eeConf,
} as ObsidianLiveSyncSettings;
return await this.onSelectServer(currentSetting, userMode);
}
/**
* Handles server selection during manual configuration
* @param currentSetting
* @param userMode
* @returns
*/
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const method = await this.dialogManager.openWithExplicitCancel(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);
}
}
// Should not reach here.
return false;
}
/**
* Confirms and applies settings obtained from the wizard
* @param newConf
* @param _userMode
* @param activate Whether to activate the remote type in the new settings
* @param extra Extra function to run before applying settings
* @returns Promise that resolves to true if settings applied successfully, false otherwise
*/
async onConfirmApplySettingsFromWizard(
newConf: ObsidianLiveSyncSettings,
_userMode: UserMode,
activate: boolean = true,
extra: () => void = () => {}
): Promise<boolean> {
let userMode = _userMode;
if (userMode === UserMode.Unknown) {
if (isObjectDifferent(this.settings, newConf, true) === false) {
this._log("No changes in settings detected. Skipping applying settings from wizard.", LOG_LEVEL_NOTICE);
return true;
}
const patch = generatePatchObj(this.settings, newConf);
console.log(`Changes:`);
console.dir(patch);
if (!activate) {
extra();
await this.applySetting(newConf, UserMode.ExistingUser);
this._log("Setting Applied", LOG_LEVEL_NOTICE);
return true;
}
// Check virtual changes
const original = { ...this.settings, P2P_DevicePeerName: "" } as ObsidianLiveSyncSettings;
const modified = { ...newConf, P2P_DevicePeerName: "" } as ObsidianLiveSyncSettings;
const isOnlyVirtualChange = isObjectDifferent(original, modified, true) === false;
if (isOnlyVirtualChange) {
extra();
await this.applySetting(newConf, UserMode.ExistingUser);
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
return true;
} else {
const userModeResult = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
if (userModeResult === "new-user") {
userMode = UserMode.NewUser;
} else if (userModeResult === "existing-user") {
userMode = UserMode.ExistingUser;
} else if (userModeResult === "compatible-existing-user") {
extra();
await this.applySetting(newConf, UserMode.ExistingUser);
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
return true;
} else if (userModeResult === "cancelled") {
this._log("User cancelled applying settings from wizard.", LOG_LEVEL_NOTICE);
return false;
}
}
}
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
const confirm = await this.dialogManager.openWithExplicitCancel(component);
if (confirm === "cancelled") {
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
return false;
}
if (confirm) {
extra();
await this.applySetting(newConf, userMode);
if (userMode === UserMode.NewUser) {
// For new users, schedule a rebuild everything.
await this.core.rebuilder.scheduleRebuild();
} else {
// For existing users, schedule a fetch.
await this.core.rebuilder.scheduleFetch();
}
}
// Settings applied, but may require rebuild to take effect.
return false;
}
/**
* Prompts the user with QR code scanning instructions
* @returns Promise that resolves to false as QR code instruction dialog does not yield settings directly
*/
async onPromptQRCodeInstruction(): Promise<boolean> {
const qrResult = await this.dialogManager.open(ScanQRCode);
this._log("QR Code dialog closed.", LOG_LEVEL_VERBOSE);
// Result is not used, but log it for debugging.
this._log(`QR Code result: ${qrResult}`, LOG_LEVEL_VERBOSE);
// QR Code instruction dialog never yields settings directly.
return false;
}
/**
* Decodes settings from a QR code string and applies them
* @param qr QR code string containing encoded settings
* @returns Promise that resolves to true if settings applied successfully, false otherwise
*/
async decodeQR(qr: string) {
const newSettings = decodeSettingsFromQRCodeData(qr);
return await this.onConfirmApplySettingsFromWizard(newSettings, UserMode.Unknown);
}
/**
* Applies the new settings to the core settings and saves them
* @param newConf
* @param userMode
* @returns Promise that resolves to true if settings applied successfully, false otherwise
*/
async applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode) {
const newSetting = {
...this.core.settings,
...newConf,
};
this.core.settings = newSetting;
this.services.setting.clearUsedPassphrase();
await this.services.setting.saveSettingData();
return true;
}
}

View File

@@ -0,0 +1,141 @@
import { eventHub, EVENT_PLUGIN_UNLOADED } from "@/common/events";
import { Modal } from "@/deps";
import type ObsidianLiveSyncPlugin from "@/main";
import { mount, unmount } from "svelte";
import DialogHost from "@lib/UI/DialogHost.svelte";
import { fireAndForget, promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import {
type DialogControlBase,
type DialogSvelteComponentBaseProps,
type ComponentHasResult,
setupDialogContext,
getDialogContext,
type SvelteDialogManagerBase,
} from "@/lib/src/UI/svelteDialog.ts";
export type DialogSvelteComponentProps = DialogSvelteComponentBaseProps & {
plugin: ObsidianLiveSyncPlugin;
services: ObsidianLiveSyncPlugin["services"];
};
export type DialogControls<T = any, U = any> = DialogControlBase<T, U> & {
plugin: ObsidianLiveSyncPlugin;
services: ObsidianLiveSyncPlugin["services"];
};
export type DialogMessageProps = Record<string, any>;
// type DialogSvelteComponent<T extends DialogSvelteComponentProps = DialogSvelteComponentProps> = Component<SvelteComponent<T>,any>;
export class SvelteDialog<T, U> extends Modal {
plugin: ObsidianLiveSyncPlugin;
mountedComponent?: ReturnType<typeof mount>;
component: ComponentHasResult<T, U>;
result?: T;
initialData?: U;
title: string = "Obsidian LiveSync - Setup Wizard";
constructor(plugin: ObsidianLiveSyncPlugin, component: ComponentHasResult<T, U>, initialData?: U) {
super(plugin.app);
this.plugin = plugin;
this.component = component;
this.initialData = initialData;
}
resolveResult() {
this.resultPromiseWithResolvers?.resolve(this.result);
this.resultPromiseWithResolvers = undefined;
}
resultPromiseWithResolvers?: PromiseWithResolvers<T | undefined>;
onOpen() {
const { contentEl } = this;
contentEl.empty();
// eslint-disable-next-line @typescript-eslint/no-this-alias
const dialog = this;
if (this.resultPromiseWithResolvers) {
this.resultPromiseWithResolvers.reject("Dialog opened again");
}
const pr = promiseWithResolvers<any>();
eventHub.once(EVENT_PLUGIN_UNLOADED, () => {
if (this.resultPromiseWithResolvers === pr) {
pr.reject("Plugin unloaded");
this.close();
}
});
this.resultPromiseWithResolvers = pr;
this.mountedComponent = mount(DialogHost, {
target: contentEl,
props: {
onSetupContext: (props: DialogSvelteComponentBaseProps) => {
setupDialogContext({
...props,
plugin: this.plugin,
services: this.plugin.services,
});
},
setTitle: (title: string) => {
dialog.setTitle(title);
},
closeDialog: () => {
dialog.close();
},
setResult: (result: T) => {
this.result = result;
},
getInitialData: () => this.initialData,
mountComponent: this.component,
},
});
}
waitForClose(): Promise<T | undefined> {
if (!this.resultPromiseWithResolvers) {
throw new Error("Dialog not opened yet");
}
return this.resultPromiseWithResolvers.promise;
}
onClose() {
this.resolveResult();
fireAndForget(async () => {
if (this.mountedComponent) {
await unmount(this.mountedComponent);
}
});
}
}
export async function openSvelteDialog<T, U>(
plugin: ObsidianLiveSyncPlugin,
component: ComponentHasResult<T, U>,
initialData?: U
): Promise<T | undefined> {
const dialog = new SvelteDialog<T, U>(plugin, component, initialData);
dialog.open();
return await dialog.waitForClose();
}
export class SvelteDialogManager implements SvelteDialogManagerBase {
plugin: ObsidianLiveSyncPlugin;
constructor(plugin: ObsidianLiveSyncPlugin) {
this.plugin = plugin;
}
async open<T, U>(component: ComponentHasResult<T, U>, initialData?: U): Promise<T | undefined> {
return await openSvelteDialog<T, U>(this.plugin, component, initialData);
}
async openWithExplicitCancel<T, U>(component: ComponentHasResult<T, U>, initialData?: U): Promise<T> {
for (let i = 0; i < 10; i++) {
const ret = await openSvelteDialog<T, U>(this.plugin, component, initialData);
if (ret !== undefined) {
return ret;
}
if (this.plugin.services.appLifecycle.hasUnloaded()) {
throw new Error("Operation cancelled due to app shutdown.");
}
Logger("Please select 'Cancel' explicitly to cancel this operation.", LOG_LEVEL_NOTICE);
}
throw new Error("Operation Forcibly cancelled by user.");
}
}
export function getObsidianDialogContext<T = any>(): DialogControls<T> {
return getDialogContext<T>() as DialogControls<T>;
}

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_IDENTICAL = "identical";
const TYPE_INDEPENDENT = "independent";
const TYPE_UNBALANCED = "unbalanced";
const TYPE_CANCEL = "cancelled";
const TYPE_BACKUP_DONE = "backup_done";
const TYPE_BACKUP_SKIPPED = "backup_skipped";
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
type ResultTypeVault =
| typeof TYPE_IDENTICAL
| typeof TYPE_INDEPENDENT
| typeof TYPE_UNBALANCED
| typeof TYPE_CANCEL;
type ResultTypeBackup =
| typeof TYPE_BACKUP_DONE
| typeof TYPE_BACKUP_SKIPPED
| typeof TYPE_UNABLE_TO_BACKUP
| typeof TYPE_CANCEL;
type ResultTypeExtra = {
preventFetchingConfig: boolean;
};
type ResultType =
| {
vault: ResultTypeVault;
backup: ResultTypeBackup;
extra: ResultTypeExtra;
}
| typeof TYPE_CANCEL;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let vaultType = $state<ResultTypeVault>(TYPE_CANCEL);
let backupType = $state<ResultTypeBackup>(TYPE_CANCEL);
const canProceed = $derived.by(() => {
return (
(vaultType === TYPE_IDENTICAL || vaultType === TYPE_INDEPENDENT || vaultType === TYPE_UNBALANCED) &&
(backupType === TYPE_BACKUP_DONE || backupType === TYPE_BACKUP_SKIPPED)
);
});
let preventFetchingConfig = $state(false);
function commit() {
setResult({
vault: vaultType,
backup: backupType,
extra: {
preventFetchingConfig,
},
});
}
</script>
<DialogHeader title="Reset Synchronisation on This Device" />
<Guidance
>This will rebuild the local database on this device using the most recent data from the server. This action is
designed to resolve synchronisation inconsistencies and restore correct functionality.</Guidance
>
<Guidance important title="⚠️ Important Notice">
<strong
>If you have unsynchronised changes in your Vault on this device, they will likely diverge from the server's
versions after the reset. This may result in a large number of file conflicts.</strong
><br />
Furthermore, if conflicts are already present in the server data, they will be synchronised to this device as they are,
and you will need to resolve them locally.
</Guidance>
<hr />
<Instruction>
<Question
><strong>To minimise the creation of new conflicts</strong>, please select the option that best describes the
current state of your Vault. The application will then check your files in the most appropriate way based on
your selection.</Question
>
<Options>
<Option
selectedValue={TYPE_IDENTICAL}
title="The files in this Vault are almost identical to the server's."
bind:value={vaultType}
>
(e.g., immediately after restoring on another computer, or having recovered from a backup)
</Option>
<Option
selectedValue={TYPE_INDEPENDENT}
title="This Vault is empty, or contains only new files that are not on the server."
bind:value={vaultType}
>
(e.g., setting up for the first time on a new smartphone, starting from a clean slate)
</Option>
<Option
selectedValue={TYPE_UNBALANCED}
title="There may be differences between the files in this Vault and the server."
bind:value={vaultType}
>
(e.g., after editing many files whilst offline)
<InfoNote info>
In this scenario, Self-hosted LiveSync will recreate metadata for every file and deliberately generate
conflicts. Where the file content is identical, these conflicts will be resolved automatically.
</InfoNote>
</Option>
</Options>
</Instruction>
<hr />
<Instruction>
<Question>Have you created a backup before proceeding?</Question>
<InfoNote>
We recommend that you copy your Vault folder to a safe location. This will provide a safeguard in case a large
number of conflicts arise, or if you accidentally synchronise with an incorrect destination.
</InfoNote>
<Options>
<Option selectedValue={TYPE_BACKUP_DONE} title="I have created a backup of my Vault." bind:value={backupType} />
<Option
selectedValue={TYPE_BACKUP_SKIPPED}
title="I understand the risks and will proceed without a backup."
bind:value={backupType}
/>
<Option
selectedValue={TYPE_UNABLE_TO_BACKUP}
title="I am unable to create a backup of my Vault."
bind:value={backupType}
>
<InfoNote error visible={backupType === TYPE_UNABLE_TO_BACKUP}>
<strong
>It is strongly advised to create a backup before proceeding. Continuing without a backup may lead
to data loss.
</strong>
<br />
If you understand the risks and still wish to proceed, select so.
</InfoNote>
</Option>
</Options>
</Instruction>
<Instruction>
<ExtraItems title="Advanced">
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
</ExtraItems>
</Instruction>
<UserDecisions>
<Decision title="Reset and Resume Synchronisation" important disabled={!canProceed} commit={() => commit()} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
</UserDecisions>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_NEW_USER = "new-user";
const TYPE_EXISTING_USER = "existing-user";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_NEW_USER | typeof TYPE_EXISTING_USER | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_NEW_USER) {
return "Yes, I want to set up a new synchronisation";
} else if (userType === TYPE_EXISTING_USER) {
return "Yes, I want to add this device to my existing synchronisation";
} else {
return "Please select an option to proceed";
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_NEW_USER || userType === TYPE_EXISTING_USER;
});
</script>
<DialogHeader title="Welcome to Self-hosted LiveSync" />
<Guidance>We will now guide you through a few questions to simplify the synchronisation setup.</Guidance>
<Instruction>
<Question>First, please select the option that best describes your current situation.</Question>
<Options>
<Option selectedValue={TYPE_NEW_USER} title="I am setting this up for the first time" bind:value={userType}>
(Select this if you are configuring this device as the first synchronisation device.) This option is
suitable if you are new to LiveSync and want to set it up from scratch.
</Option>
<Option
selectedValue={TYPE_EXISTING_USER}
title="I am adding a device to an existing synchronisation setup"
bind:value={userType}
>
(Select this if you are already using synchronisation on another computer or smartphone.) This option is
suitable if you are new to LiveSync and want to set it up from scratch.
</Option>
</Options>
</Instruction>
<UserDecisions>
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
const TYPE_EXISTING = "existing-user";
const TYPE_NEW = "new-user";
const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_EXISTING | typeof TYPE_NEW | typeof TYPE_COMPATIBLE_EXISTING | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
const canProceed = $derived.by(() => {
return userType === TYPE_EXISTING || userType === TYPE_NEW || userType === TYPE_COMPATIBLE_EXISTING;
});
const proceedMessage = $derived.by(() => {
if (userType === TYPE_NEW) {
return "Proceed to the next step.";
} else if (userType === TYPE_EXISTING) {
return "Proceed to the next step.";
} else if (userType === TYPE_COMPATIBLE_EXISTING) {
return "Apply the settings";
} else {
return "Please select an option to proceed";
}
});
</script>
<DialogHeader title="Mostly Complete: Decision Required" />
<Guidance>
The connection to the server has been configured successfully. As the next step, <strong
>the local database, that is to say the synchronisation information, must be reconstituted.</strong
>
</Guidance>
<Instruction>
<Question>Please select your situation.</Question>
<Option title="I am setting up a new server for the first time / I want to reset my existing server." bind:value={userType} selectedValue={TYPE_NEW}>
<InfoNote>
Selecting this option will result in the current data on this device being used to initialise the server.
Any existing data on the server will be completely overwritten.
</InfoNote>
</Option>
<Option
title="My remote server is already set up. I want to join this device."
bind:value={userType}
selectedValue={TYPE_EXISTING}
>
<InfoNote>
Selecting this option will result in this device joining the existing server. You need to fetching the
existing synchronisation data from the server to this device.
</InfoNote>
</Option>
<Option
title="The remote is already set up, and the configuration is compatible (or got compatible by this operation)."
bind:value={userType}
selectedValue={TYPE_COMPATIBLE_EXISTING}
>
<InfoNote warning>
Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is
compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you
are doing.
</InfoNote>
</Option>
</Instruction>
<UserDecisions>
<Decision title={proceedMessage} important={true} disabled={!canProceed} commit={() => setResult(userType)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_APPLY = "apply";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
</script>
<DialogHeader title="Setup Complete: Preparing to Fetch Synchronisation Data" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the latest synchronisation data will be downloaded from the server to this device.</strong
>
</p>
<p>
<strong>PLEASE NOTE</strong>
<br />
After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised
files in this vault, conflicts may occur with the server data.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the data fetching confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Fetch Data" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_APPLY = "apply";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
// let userType = $state<ResultType>(TYPE_CANCELLED);
</script>
<DialogHeader title="Setup Complete: Preparing to Initialise Server" />
<Guidance>
<p>
The connection to the server has been configured successfully. As the next step, <strong
>the synchronisation data on the server will be built based on the current data on this device.</strong
>
</p>
<p>
<strong>IMPORTANT</strong>
<br />
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that
any unintended data currently on the server will be completely overwritten.
</p>
</Guidance>
<Instruction>
<Question>Please select the button below to restart and proceed to the final confirmation.</Question>
</Instruction>
<UserDecisions>
<Decision title="Restart and Initialise Server" important={true} commit={() => setResult(TYPE_APPLY)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
/**
* Panel to check and fix CouchDB configuration issues
*/
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
import Decision from "../../../../lib/src/UI/components/Decision.svelte";
import UserDecisions from "../../../../lib/src/UI/components/UserDecisions.svelte";
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
type Props = {
trialRemoteSetting: ObsidianLiveSyncSettings;
};
const { trialRemoteSetting }: Props = $props();
let detectedIssues = $state<ConfigCheckResult[]>([]);
async function testAndFixSettings() {
detectedIssues = [];
try {
const fixResults = await checkConfig(trialRemoteSetting);
console.dir(fixResults);
detectedIssues = fixResults;
} catch (e) {
console.error("Error during testAndFixSettings:", e);
detectedIssues.push({ message: `Error during testAndFixSettings: ${e}`, result: "error", classes: [] });
}
}
function isErrorResult(result: ConfigCheckResult): result is ResultError | ResultErrorMessage {
return "result" in result && result.result === "error";
}
function isFixableError(result: ConfigCheckResult): result is ResultError {
return isErrorResult(result) && "fix" in result && typeof result.fix === "function";
}
function isSuccessResult(result: ConfigCheckResult): result is { message: string; result: "ok"; value?: any } {
return "result" in result && result.result === "ok";
}
let processing = $state(false);
async function fixIssue(issue: ResultError) {
try {
processing = true;
await issue.fix();
} catch (e) {
console.error("Error during fixIssue:", e);
}
await testAndFixSettings();
processing = false;
}
const errorIssueCount = $derived.by(() => {
return detectedIssues.filter((issue) => isErrorResult(issue)).length;
});
const isAllSuccess = $derived.by(() => {
return !(errorIssueCount > 0 && detectedIssues.length > 0);
});
</script>
{#snippet result(issue: ConfigCheckResult)}
<div class="check-result {isErrorResult(issue) ? 'error' : isSuccessResult(issue) ? 'success' : ''}">
<div class="message">
{issue.message}
</div>
{#if isFixableError(issue)}
<div class="operations">
<button onclick={() => fixIssue(issue)} class="mod-cta" disabled={processing}>Fix</button>
</div>
{/if}
</div>
{/snippet}
<UserDecisions>
<Decision title="Detect and Fix CouchDB Issues" important={true} commit={testAndFixSettings} />
</UserDecisions>
<div class="check-results">
<details open={!isAllSuccess}>
<summary>
{#if detectedIssues.length === 0}
No checks have been performed yet.
{:else if isAllSuccess}
All checks passed successfully!
{:else}
{errorIssueCount} issue(s) detected!
{/if}
</summary>
{#if detectedIssues.length > 0}
<h3>Issue detection log:</h3>
{#each detectedIssues as issue}
{@render result(issue)}
{/each}
{/if}
</details>
</div>
<style>
/* Make .check-result a CSS Grid: let .message expand and keep .operations at minimum width, aligned to the right */
.check-results {
/* Adjust spacing as required */
margin-top: 0.75rem;
}
.check-result {
display: grid;
grid-template-columns: 1fr auto; /* message takes remaining space, operations use minimum width */
align-items: center; /* vertically centre align */
gap: 0.5rem 1rem;
padding: 0rem 0.5rem;
border-radius: 0;
box-shadow: none;
border-left: 0.5em solid var(--interactive-accent);
margin-bottom: 0.25lh;
}
.check-result.error {
border-left: 0.5em solid var(--text-error);
}
.check-result.success {
border-left: 0.5em solid var(--text-success);
}
.check-result .message {
/* Wrap long messages */
white-space: normal;
word-break: break-word;
font-size: 0.95rem;
color: var(--text-normal);
}
.check-result .operations {
/* Centre the button(s) vertically and align to the right */
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
/* For small screens: move .operations below and stack vertically */
@media (max-width: 520px) {
.check-result {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
.check-result .operations {
justify-content: flex-start;
margin-top: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_CANCEL = "cancelled";
const TYPE_BACKUP_DONE = "backup_done";
const TYPE_BACKUP_SKIPPED = "backup_skipped";
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
type ResultTypeBackup =
| typeof TYPE_BACKUP_DONE
| typeof TYPE_BACKUP_SKIPPED
| typeof TYPE_UNABLE_TO_BACKUP
| typeof TYPE_CANCEL;
type ResultTypeExtra = {
preventFetchingConfig: boolean;
};
type ResultType =
| {
backup: ResultTypeBackup;
extra: ResultTypeExtra;
}
| typeof TYPE_CANCEL;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let backupType = $state<ResultTypeBackup>(TYPE_CANCEL);
let confirmationCheck1 = $state(false);
let confirmationCheck2 = $state(false);
let confirmationCheck3 = $state(false);
const canProceed = $derived.by(() => {
return (
(backupType === TYPE_BACKUP_DONE || backupType === TYPE_BACKUP_SKIPPED) &&
confirmationCheck1 &&
confirmationCheck2 &&
confirmationCheck3
);
});
let preventFetchingConfig = $state(false);
function commit() {
setResult({
backup: backupType,
extra: {
preventFetchingConfig,
},
});
}
</script>
<DialogHeader title="Final Confirmation: Overwrite Server Data with This Device's Files" />
<Guidance
>This procedure will first delete all existing synchronisation data from the server. Following this, the server data
will be completely rebuilt, using the current state of your Vault on this device (including its local database) as
<strong>the single, authoritative master copy</strong>.</Guidance
>
<InfoNote>
You should perform this operation only in exceptional circumstances, such as when the server data is completely
corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually
large in comparison to the Vault size.
</InfoNote>
<Guidance important title="⚠️ Please Confirm the Following">
<Check
title="I understand that all changes made on other smartphones or computers possibly could be lost."
bind:value={confirmationCheck1}
>
<InfoNote>There is a way to resolve this on other devices.</InfoNote>
<InfoNote>Of course, we can back up the data before proceeding.</InfoNote>
</Check>
<Check
title="I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information."
bind:value={confirmationCheck2}
>
<InfoNote>by resetting the remote, you will be informed on other devices.</InfoNote>
</Check>
<Check title="I understand that this action is irreversible once performed." bind:value={confirmationCheck3} />
</Guidance>
<hr />
<Instruction>
<Question>Have you created a backup before proceeding?</Question>
<InfoNote warning>
This is an extremely powerful operation. We strongly recommend that you copy your Vault folder to a safe
location.
</InfoNote>
<Options>
<Option selectedValue={TYPE_BACKUP_DONE} title="I have created a backup of my Vault." bind:value={backupType} />
<Option
selectedValue={TYPE_BACKUP_SKIPPED}
title="I understand the risks and will proceed without a backup."
bind:value={backupType}
/>
<Option
selectedValue={TYPE_UNABLE_TO_BACKUP}
title="I am unable to create a backup of my Vaults."
bind:value={backupType}
>
<InfoNote error visible={backupType === TYPE_UNABLE_TO_BACKUP}>
<strong
>You should create a new synchronisation destination and rebuild your data there. <br /> After that,
synchronise to a brand new vault on each other device with the new remote one by one.</strong
>
</InfoNote>
</Option>
</Options>
</Instruction>
<Instruction>
<ExtraItems title="Advanced">
<Check title="Prevent fetching configuration from server" bind:value={preventFetchingConfig} />
</ExtraItems>
</Instruction>
<UserDecisions>
<Decision title="I Understand, Overwrite Server" important disabled={!canProceed} commit={() => commit()} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCEL)} />
</UserDecisions>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_CLOSE = "close";
type ResultType = typeof TYPE_CLOSE;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
</script>
<DialogHeader title="Scan QR Code" />
<Guidance>Please follow the steps below to import settings from your existing device.</Guidance>
<Instruction>
<!-- <Question>How would you like to configure the connection to your server?</Question> -->
<ol>
<li>On this device, please keep this Vault open.</li>
<li>On the source device, open Obsidian.</li>
<li>On the source device, from the command palette, run the 'Show settings as a QR code' command.</li>
<li>On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.</li>
</ol>
</Instruction>
<UserDecisions>
<Decision title="Close this dialog" important={true} commit={() => setResult(TYPE_CLOSE)} />
</UserDecisions>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_USE_SETUP_URI = "use-setup-uri";
const TYPE_SCAN_QR_CODE = "scan-qr-code";
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_SCAN_QR_CODE | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_USE_SETUP_URI) {
return "Proceed with Setup URI";
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
return "I know my server details, let me enter them";
} else if (userType === TYPE_SCAN_QR_CODE) {
return "Scan the QR code displayed on an active device using this device's camera.";
} else {
return "Please select an option to proceed";
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_USE_SETUP_URI || userType === TYPE_CONFIGURE_MANUALLY || userType === TYPE_SCAN_QR_CODE;
});
</script>
<DialogHeader title="Device Setup Method" />
<Guidance>You are adding this device to an existing synchronisation setup.</Guidance>
<Instruction>
<Question>Please select a method to import the settings from another device.</Question>
<Options>
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
Paste the Setup URI generated from one of your active devices.
</Option>
<Option selectedValue={TYPE_SCAN_QR_CODE} title="Scan a QR Code (Recommended for mobile)" bind:value={userType}>
Scan the QR code displayed on an active device using this device's camera.
</Option>
<Option
selectedValue={TYPE_CONFIGURE_MANUALLY}
title="Enter the server information manually"
bind:value={userType}
>
Configure the same server information as your other devices again, manually, very advanced users only.
</Option>
</Options>
</Instruction>
<UserDecisions>
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_USE_SETUP_URI = "use-setup-uri";
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_USE_SETUP_URI) {
return "Proceed with Setup URI";
} else if (userType === TYPE_CONFIGURE_MANUALLY) {
return "I know my server details, let me enter them";
} else {
return "Please select an option to proceed";
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_USE_SETUP_URI || userType === TYPE_CONFIGURE_MANUALLY;
});
</script>
<DialogHeader title="Connection Method" />
<Guidance>We will now proceed with the server configuration.</Guidance>
<Instruction>
<Question>How would you like to configure the connection to your server?</Question>
<Options>
<Option selectedValue={TYPE_USE_SETUP_URI} title="Use a Setup URI (Recommended)" bind:value={userType}>
A Setup URI is a single string of text containing your server address and authentication details. Using a
URI, if one was generated by your server installation script, provides a simple and secure configuration.
</Option>
<Option
selectedValue={TYPE_CONFIGURE_MANUALLY}
title="Enter the server information manually"
bind:value={userType}
>
This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
</Option>
</Options>
</Instruction>
<UserDecisions>
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_COUCHDB = "couchdb";
const TYPE_BUCKET = "bucket";
const TYPE_P2P = "p2p";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
type Props = {
setResult: (result: ResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_COUCHDB) {
return "Continue to CouchDB setup";
} else if (userType === TYPE_BUCKET) {
return "Continue to S3/MinIO/R2 setup";
} else if (userType === TYPE_P2P) {
return "Continue to Peer-to-Peer only setup";
} else {
return "Please select an option to proceed";
}
});
const canProceed = $derived.by(() => {
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
});
</script>
<DialogHeader title="Enter Server Information" />
<Instruction>
<Question>Please select the type of server to which you are connecting.</Question>
<Options>
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
This is the most suitable synchronisation method for the design. All functions are available. You must have
set up a CouchDB instance.
</Option>
<Option selectedValue={TYPE_BUCKET} title="S3/MinIO/R2 Object Storage" bind:value={userType}>
Synchronisation utilising journal files. You must have set up an S3/MinIO/R2 compatible object storage.
</Option>
<Option selectedValue={TYPE_P2P} title="Peer-to-Peer only" bind:value={userType}>
This is an experimental feature enabling direct synchronisation between devices. No server is required, but
both devices must be online at the same time for synchronisation to occur, and some features may be limited.
Internet connection is only required to signalling (detecting peers) and not for data transfer.
</Option>
</Options>
</Instruction>
<UserDecisions>
<Decision title={proceedTitle} important={canProceed} disabled={!canProceed} commit={() => setResult(userType)} />
<Decision title="No, please take me back" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,241 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
import Password from "@/lib/src/UI/components/Password.svelte";
import {
type BucketSyncSetting,
type ObsidianLiveSyncSettings,
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
RemoteTypes,
} from "../../../../lib/src/common/types";
import { onMount } from "svelte";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { getObsidianDialogContext } from "../ObsidianSvelteDialog";
import { copyTo, pickBucketSyncSettings } from "../../../../lib/src/common/utils";
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<BucketSyncSetting>({ ...default_setting });
type ResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
type Props = GuestDialogProps<ResultType, BucketSyncSetting>;
const TYPE_CANCELLED = "cancelled";
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
}
}
});
let error = $state("");
const context = getObsidianDialogContext();
const isEndpointSecure = $derived.by(() => {
return syncSetting.endpoint.trim().toLowerCase().startsWith("https://");
});
const isEndpointInsecure = $derived.by(() => {
return syncSetting.endpoint.trim().toLowerCase().startsWith("http://");
});
const isEndpointSupplied = $derived.by(() => {
return isEndpointInsecure || isEndpointSecure;
});
const canProceed = $derived.by(() => {
return (
syncSetting.accessKey.trim() !== "" &&
syncSetting.secretKey.trim() !== "" &&
syncSetting.bucket.trim() !== "" &&
syncSetting.endpoint.trim() !== "" &&
syncSetting.region.trim() !== "" &&
isEndpointSupplied
);
});
function generateSetting() {
const connSetting: BucketSyncSetting = {
...syncSetting,
};
const trialSettings: BucketSyncSetting = {
...connSetting,
};
const trialRemoteSetting: ObsidianLiveSyncSettings = {
...DEFAULT_SETTINGS,
...PREFERRED_JOURNAL_SYNC,
remoteType: RemoteTypes.REMOTE_MINIO,
...trialSettings,
};
return trialRemoteSetting;
}
let processing = $state(false);
async function checkConnection() {
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
return "Failed to create replicator instance.";
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
return "";
} else {
return "Failed to connect to the server. Please check your settings.";
}
} catch (e) {
return `Failed to connect to the server: ${e}`;
}
} finally {
processing = false;
}
}
async function checkAndCommit() {
error = "";
try {
error = (await checkConnection()) || "";
if (!error) {
const setting = generateSetting();
setResult(pickBucketSyncSettings(setting));
return;
}
} catch (e) {
error = `Error during connection test: ${e}`;
return;
}
}
function commit() {
const setting = pickBucketSyncSettings(generateSetting());
setResult(setting);
}
function cancel() {
setResult(TYPE_CANCELLED);
}
</script>
<DialogHeader title="S3/MinIO/R2 Configuration" />
<Guidance>Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.</Guidance>
<InputRow label="Endpoint URL">
<input
type="text"
name="s3-endpoint"
placeholder="https://s3.amazonaws.com"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^https?://.+"
bind:value={syncSetting.endpoint}
/>
</InputRow>
<InfoNote warning visible={isEndpointInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
<InputRow label="Access Key ID">
<input
type="text"
name="s3-access-key-id"
placeholder="Enter your Access Key ID"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.accessKey}
/>
</InputRow>
<InputRow label="Secret Access Key">
<Password
name="s3-secret-access-key"
placeholder="Enter your Secret Access Key"
required
bind:value={syncSetting.secretKey}
/>
</InputRow>
<InputRow label="Bucket Name">
<input
type="text"
name="s3-bucket-name"
placeholder="Enter your Bucket Name"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.bucket}
/></InputRow
>
<InputRow label="Region">
<input
type="text"
name="s3-region"
placeholder="Enter your Region (e.g., us-east-1, auto for R2)"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.region}
/>
</InputRow>
<InputRow label="Use Path-Style Access">
<input type="checkbox" name="s3-use-path-style" bind:checked={syncSetting.forcePathStyle} />
</InputRow>
<InputRow label="Folder Prefix">
<input
type="text"
name="s3-folder-prefix"
placeholder="Enter a folder prefix (optional)"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.bucketPrefix}
/>
</InputRow>
<InfoNote>
If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here.
Otherwise, leave it blank to store data at the root of the bucket.
</InfoNote>
<InputRow label="Use internal API">
<input type="checkbox" name="s3-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
</InputRow>
<InfoNote>
If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate
with the S3 server. Not compliant with web standards, but works. Note that this might break in future Obsidian
versions.
</InfoNote>
<ExtraItems title="Advanced Settings">
<InputRow label="Custom Headers">
<textarea
name="bucket-custom-headers"
placeholder="e.g., x-example-header: value\n another-header: value2"
bind:value={syncSetting.bucketCustomHeaders}
autocapitalize="off"
spellcheck="false"
rows="4"
></textarea>
</InputRow>
</ExtraItems>
<InfoNote error visible={error !== ""}>
{error}
</InfoNote>
{#if processing}
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} />
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}

View File

@@ -0,0 +1,286 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
import Password from "@/lib/src/UI/components/Password.svelte";
import {
DEFAULT_SETTINGS,
PREFERRED_SETTING_CLOUDANT,
PREFERRED_SETTING_SELF_HOSTED,
RemoteTypes,
type CouchDBConnection,
type ObsidianLiveSyncSettings,
} from "../../../../lib/src/common/types";
import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
import { getObsidianDialogContext } from "../ObsidianSvelteDialog";
import { onMount } from "svelte";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "../../../../lib/src/common/utils";
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
type ResultType = typeof TYPE_CANCELLED | CouchDBConnection;
const TYPE_CANCELLED = "cancelled";
type Props = GuestDialogProps<ResultType, CouchDBConnection>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
}
}
});
let error = $state("");
const context = getObsidianDialogContext();
function generateSetting() {
const connSetting: CouchDBConnection = {
...syncSetting,
};
const trialSettings: CouchDBConnection = {
...connSetting,
// ...encryptionSettings,
};
const preferredSetting = isCloudantURI(syncSetting.couchDB_URI)
? PREFERRED_SETTING_CLOUDANT
: PREFERRED_SETTING_SELF_HOSTED;
const trialRemoteSetting: ObsidianLiveSyncSettings = {
...DEFAULT_SETTINGS,
...preferredSetting,
remoteType: RemoteTypes.REMOTE_COUCHDB,
...trialSettings,
};
return trialRemoteSetting;
}
let processing = $state(false);
async function checkConnection() {
try {
processing = true;
const trialRemoteSetting = generateSetting();
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
if (!replicator) {
return "Failed to create replicator instance.";
}
try {
const result = await replicator.tryConnectRemote(trialRemoteSetting, false);
if (result) {
return "";
} else {
return "Failed to connect to the server. Please check your settings.";
}
} catch (e) {
return `Failed to connect to the server: ${e}`;
}
} finally {
processing = false;
}
}
async function checkAndCommit() {
error = "";
try {
error = (await checkConnection()) || "";
if (!error) {
const setting = generateSetting();
setResult(pickCouchDBSyncSettings(setting));
return;
}
} catch (e) {
error = `Error during connection test: ${e}`;
return;
}
}
function commit() {
const setting = pickCouchDBSyncSettings(generateSetting());
setResult(setting);
}
function cancel() {
setResult(TYPE_CANCELLED);
}
// const isURICloudant = $derived.by(() => {
// return syncSetting.couchDB_URI && isCloudantURI(syncSetting.couchDB_URI);
// });
// const isURISelfHosted = $derived.by(() => {
// return syncSetting.couchDB_URI && !isCloudantURI(syncSetting.couchDB_URI);
// });
// const isURISecure = $derived.by(() => {
// return syncSetting.couchDB_URI && syncSetting.couchDB_URI.startsWith("https://");
// });
const isURIInsecure = $derived.by(() => {
return !!(syncSetting.couchDB_URI && syncSetting.couchDB_URI.startsWith("http://"));
});
const isUseJWT = $derived.by(() => {
return syncSetting.useJWT;
});
const canProceed = $derived.by(() => {
return (
syncSetting.couchDB_URI.trim().length > 0 &&
syncSetting.couchDB_USER.trim().length > 0 &&
syncSetting.couchDB_PASSWORD.trim().length > 0 &&
syncSetting.couchDB_DBNAME.trim().length > 0 &&
(isUseJWT ? syncSetting.jwtKey.trim().length > 0 : true)
);
});
const testSettings = $derived.by(() => {
return generateSetting();
});
</script>
<DialogHeader title="CouchDB Configuration" />
<Guidance>Please enter the CouchDB server information below.</Guidance>
<InputRow label="URL">
<input
type="text"
name="couchdb-url"
placeholder="https://example.com"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.couchDB_URI}
required
pattern="^https?://.+"
/>
</InputRow>
<InfoNote warning visible={isURIInsecure}>We can use only Secure (HTTPS) connections on Obsidian Mobile.</InfoNote>
<InputRow label="Username">
<input
type="text"
name="couchdb-username"
placeholder="Enter your username"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
bind:value={syncSetting.couchDB_USER}
/>
</InputRow>
<InputRow label="Password">
<Password
name="couchdb-password"
placeholder="Enter your password"
bind:value={syncSetting.couchDB_PASSWORD}
required
/>
</InputRow>
<InputRow label="Database Name">
<input
type="text"
name="couchdb-database"
placeholder="Enter your database name"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
pattern="^[a-z0-9][a-z0-9_]*$"
bind:value={syncSetting.couchDB_DBNAME}
/>
</InputRow>
<InfoNote>
You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an
underscore (_).
</InfoNote>
<InputRow label="Use Internal API">
<input type="checkbox" name="couchdb-use-internal-api" bind:checked={syncSetting.useRequestAPI} />
</InputRow>
<InfoNote>
If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate
with the CouchDB server. Not compliant with web standards, but works. Note that this might break in future Obsidian
versions.
</InfoNote>
<ExtraItems title="Advanced Settings">
<InputRow label="Custom Headers">
<textarea
name="couchdb-custom-headers"
placeholder="e.g., x-example-header: value\n another-header: value2"
bind:value={syncSetting.couchDB_CustomHeaders}
autocapitalize="off"
spellcheck="false"
rows="4"
></textarea>
</InputRow>
</ExtraItems>
<ExtraItems title="Experimental Settings">
<InputRow label="Use JWT Authentication">
<input type="checkbox" name="couchdb-use-jwt" bind:checked={syncSetting.useJWT} />
</InputRow>
<InputRow label="JWT Algorithm">
<select bind:value={syncSetting.jwtAlgorithm} disabled={!isUseJWT}>
<option value="HS256">HS256</option>
<option value="HS512">HS512</option>
<option value="ES256">ES256</option>
<option value="ES512">ES512</option>
</select>
</InputRow>
<InputRow label="JWT Expiration Duration (minutes)">
<input
type="text"
name="couchdb-jwt-exp-duration"
placeholder="0"
bind:value={() => `${syncSetting.jwtExpDuration}`, (v) => (syncSetting.jwtExpDuration = parseInt(v) || 0)}
disabled={!isUseJWT}
/>
</InputRow>
<InputRow label="JWT Key">
<textarea
name="couchdb-jwt-key"
rows="5"
autocapitalize="off"
spellcheck="false"
placeholder="Enter your JWT secret or private key"
bind:value={syncSetting.jwtKey}
disabled={!isUseJWT}
></textarea>
</InputRow>
<InputRow label="JWT Key ID (kid)">
<input
type="text"
name="couchdb-jwt-kid"
placeholder="Enter your JWT Key ID"
bind:value={syncSetting.jwtKid}
disabled={!isUseJWT}
/>
</InputRow>
<InputRow label="JWT Subject (sub)">
<input
type="text"
name="couchdb-jwt-sub"
placeholder="Enter your JWT Subject (CouchDB Username)"
bind:value={syncSetting.jwtSub}
disabled={!isUseJWT}
/>
</InputRow>
<InfoNote warning>
JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens.
Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the
server's configuration. Incidentally, I have not verified it very thoroughly.
</InfoNote>
</ExtraItems>
<PanelCouchDBCheck trialRemoteSetting={testSettings}></PanelCouchDBCheck>
<hr />
<InfoNote error visible={error !== ""}>
{error}
</InfoNote>
{#if processing}
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} />
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
import Password from "@/lib/src/UI/components/Password.svelte";
import {
DEFAULT_SETTINGS,
E2EEAlgorithmNames,
E2EEAlgorithms,
type EncryptionSettings,
} from "../../../../lib/src/common/types";
import { onMount } from "svelte";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { copyTo, pickEncryptionSettings } from "../../../../lib/src/common/utils";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_CANCELLED | EncryptionSettings;
type Props = GuestDialogProps<ResultType, EncryptionSettings>;
const { setResult, getInitialData }: Props = $props();
let default_encryption: EncryptionSettings = {
encrypt: true,
passphrase: "",
E2EEAlgorithm: DEFAULT_SETTINGS.E2EEAlgorithm,
usePathObfuscation: true,
} as EncryptionSettings;
let encryptionSettings = $state<EncryptionSettings>({ ...default_encryption });
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, encryptionSettings);
}
}
});
let e2eeValid = $derived.by(() => {
if (!encryptionSettings.encrypt) return true;
return encryptionSettings.passphrase.trim().length >= 1;
});
function commit() {
setResult(pickEncryptionSettings(encryptionSettings));
}
</script>
<DialogHeader title="End-to-End Encryption" />
<Guidance>Please configure your end-to-end encryption settings.</Guidance>
<InputRow label="End-to-End Encryption">
<input type="checkbox" bind:checked={encryptionSettings.encrypt} />
<Password
name="e2ee-passphrase"
placeholder="Enter your passphrase"
bind:value={encryptionSettings.passphrase}
disabled={!encryptionSettings.encrypt}
required={encryptionSettings.encrypt}
/>
</InputRow>
<InfoNote title="Strongly Recommended">
Enabling end-to-end encryption ensures that your data is encrypted on your device before being sent to the remote
server. This means that even if someone gains access to the server, they won't be able to read your data without the
passphrase. Make sure to remember your passphrase, as it will be required to decrypt your data on other devices.
<br />
Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch
to other methods and connect to a remote server in the future.
</InfoNote>
<InfoNote warning>
This setting must be the same even when connecting to multiple synchronisation destinations.
</InfoNote>
<InputRow label="Obfuscate Properties">
<input
type="checkbox"
bind:checked={encryptionSettings.usePathObfuscation}
disabled={!encryptionSettings.encrypt}
/>
</InputRow>
<InfoNote>
Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of
security by making it harder to identify the structure and names of your files and folders on the remote server.
This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your
data.
</InfoNote>
<ExtraItems title="Advanced">
<InputRow label="Encryption Algorithm">
<select bind:value={encryptionSettings.E2EEAlgorithm} disabled={!encryptionSettings.encrypt}>
{#each Object.values(E2EEAlgorithms) as alg}
<option value={alg}>{E2EEAlgorithmNames[alg] ?? alg}</option>
{/each}
</select>
</InputRow>
<InfoNote>
In most cases, you should stick with the default algorithm ({E2EEAlgorithmNames[
DEFAULT_SETTINGS.E2EEAlgorithm
]}), This setting is only required if you have an existing Vault encrypted in a different format.
</InfoNote>
<InfoNote warning>
Changing the encryption algorithm will prevent access to any data previously encrypted with a different
algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your
data.
</InfoNote>
</ExtraItems>
<InfoNote warning>
<p>
Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process
actually commences. This is a security measure designed to protect your data.
</p>
<p>
Therefore, we ask that you exercise extreme caution when configuring server information manually. If an
incorrect passphrase is entered, the data on the server will become corrupted. <br /><br />
Please understand that this is intended behaviour.
</p>
</InfoNote>
<UserDecisions>
<Decision title="Proceed" important disabled={!e2eeValid} commit={() => commit()} />
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,306 @@
<script lang="ts">
// import { delay } from "octagonal-wheels/promises";
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
import Password from "@/lib/src/UI/components/Password.svelte";
import { PouchDB } from "../../../../lib/src/pouchdb/pouchdb-browser";
import {
DEFAULT_SETTINGS,
P2P_DEFAULT_SETTINGS,
PREFERRED_BASE,
RemoteTypes,
type EntryDoc,
type ObsidianLiveSyncSettings,
type P2PConnectionInfo,
type P2PSyncSetting,
} from "../../../../lib/src/common/types";
import { TrysteroReplicator } from "../../../../lib/src/replication/trystero/TrysteroReplicator";
import type { ReplicatorHostEnv } from "../../../../lib/src/replication/trystero/types";
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "../../../../lib/src/common/utils";
import { getObsidianDialogContext } from "../ObsidianSvelteDialog";
import { onMount } from "svelte";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../../lib/src/common/types";
import ExtraItems from "../../../../lib/src/UI/components/ExtraItems.svelte";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
const context = getObsidianDialogContext();
let error = $state("");
const TYPE_CANCELLED = "cancelled";
type SettingInfo = P2PConnectionInfo;
type ResultType = typeof TYPE_CANCELLED | SettingInfo;
type Props = GuestDialogProps<ResultType, P2PSyncSetting>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {
const initialData = getInitialData();
if (initialData) {
copyTo(initialData, syncSetting);
}
if (context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME)) {
syncSetting.P2P_DevicePeerName = context.services.config.getSmallConfig(
SETTING_KEY_P2P_DEVICE_NAME
) as string;
} else {
syncSetting.P2P_DevicePeerName = "";
}
}
});
function generateSetting() {
const connSetting: P2PSyncSetting = {
// remoteType: ",
...P2P_DEFAULT_SETTINGS,
...syncSetting,
P2P_Enabled: true,
};
const trialSettings: P2PSyncSetting = {
...connSetting,
};
const trialRemoteSetting: ObsidianLiveSyncSettings = {
...DEFAULT_SETTINGS,
...PREFERRED_BASE,
remoteType: RemoteTypes.REMOTE_P2P,
...trialSettings,
};
return trialRemoteSetting;
}
async function checkConnection() {
try {
processing = true;
const trialRemoteSetting = generateSetting();
const map = new Map<string, string>();
const store = {
get: (key: string) => {
return Promise.resolve(map.get(key) || null);
},
set: (key: string, value: any) => {
map.set(key, value);
return Promise.resolve();
},
delete: (key: string) => {
map.delete(key);
return Promise.resolve();
},
keys: () => {
return Promise.resolve(Array.from(map.keys()));
},
} as SimpleStore<any>;
const dummyPouch = new PouchDB<EntryDoc>("dummy");
const env: ReplicatorHostEnv = {
settings: trialRemoteSetting,
processReplicatedDocs: async (docs: any[]) => {
return;
},
confirm: context.plugin.confirm,
db: dummyPouch,
simpleStore: store,
deviceName: syncSetting.P2P_DevicePeerName || "unnamed-device",
platform: "setup-wizard",
};
const replicator = new TrysteroReplicator(env);
try {
await replicator.setOnSetup();
await replicator.allowReconnection();
await replicator.open();
for (let i = 0; i < 10; i++) {
// await delay(1000);
await new Promise((resolve) => setTimeout(resolve, 1000));
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
if (replicator.knownAdvertisements.length > 0) {
break;
}
}
// context.holdingSettings = trialRemoteSetting;
if (replicator.knownAdvertisements.length === 0) {
return "Your settings seem correct, but no other peers were found.";
}
return "";
} catch (e) {
return `Failed to connect to other peers: ${e}`;
} finally {
try {
replicator.close();
dummyPouch.destroy();
} catch (e) {
console.error(e);
}
}
} finally {
processing = false;
}
}
function setDefaultRelay() {
syncSetting.P2P_relays = P2P_DEFAULT_SETTINGS.P2P_relays;
}
let processing = $state(false);
function generateDefaultGroupId() {
const randomValues = new Uint16Array(4);
crypto.getRandomValues(randomValues);
const MAX_UINT16 = 65536;
const a = Math.floor((randomValues[0] / MAX_UINT16) * 1000);
const b = Math.floor((randomValues[1] / MAX_UINT16) * 1000);
const c = Math.floor((randomValues[2] / MAX_UINT16) * 1000);
const d_range = 36 * 36 * 36;
const d = Math.floor((randomValues[3] / MAX_UINT16) * d_range);
syncSetting.P2P_roomID = `${a.toString().padStart(3, "0")}-${b
.toString()
.padStart(3, "0")}-${c.toString().padStart(3, "0")}-${d.toString(36).padStart(3, "0")}`;
}
async function checkAndCommit() {
error = "";
try {
error = (await checkConnection()) || "";
if (!error) {
const setting = generateSetting();
setResult(pickP2PSyncSettings(setting));
return;
}
} catch (e) {
error = `Error during connection test: ${e}`;
return;
}
}
function commit() {
const setting = pickP2PSyncSettings(generateSetting());
setResult(setting);
}
function cancel() {
setResult(TYPE_CANCELLED);
}
const canProceed = $derived.by(() => {
return (
syncSetting.P2P_relays.trim() !== "" &&
syncSetting.P2P_roomID.trim() !== "" &&
syncSetting.P2P_passphrase.trim() !== "" &&
(syncSetting.P2P_DevicePeerName ?? "").trim() !== ""
);
});
</script>
<DialogHeader title="P2P Configuration" />
<Guidance>Please enter the Peer-to-Peer Synchronisation information below.</Guidance>
<InputRow label="Enabled">
<input type="checkbox" name="p2p-enabled" bind:checked={syncSetting.P2P_Enabled} />
</InputRow>
<InputRow label="Relay URL">
<input
type="text"
name="p2p-relay-url"
placeholder="Enter the Relay URL)"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_relays}
/>
<button class="button" onclick={() => setDefaultRelay()}>Use vrtmrz's relay</button>
</InputRow>
<InputRow label="Group ID">
<input
type="text"
name="p2p-room-id"
placeholder="123-456-789-abc"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_roomID}
/>
<button class="button" onclick={() => generateDefaultGroupId()}>Generate Random ID</button>
</InputRow>
<InputRow label="Passphrase">
<Password name="p2p-password" placeholder="Enter your passphrase" bind:value={syncSetting.P2P_passphrase} />
</InputRow>
<InfoNote>
The Group ID and passphrase are used to identify your group of devices. Make sure to use the same Group ID and
passphrase on all devices you want to synchronise.<br />
Note that the Group ID is not limited to the generated format; you can use any string as the Group ID.
</InfoNote>
<InputRow label="Device Peer ID">
<input
type="text"
name="p2p-device-peer-id"
placeholder="main-iphone16"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_DevicePeerName}
/>
</InputRow>
<InputRow label="Auto Start P2P Connection">
<input type="checkbox" name="p2p-auto-start" bind:checked={syncSetting.P2P_AutoStart} />
</InputRow>
<InfoNote>
If "Auto Start P2P Connection" is enabled, the P2P connection will be started automatically when the plug-in
launches.
</InfoNote>
<InputRow label="Auto Broadcast Changes">
<input type="checkbox" name="p2p-auto-broadcast" bind:checked={syncSetting.P2P_AutoBroadcast} />
</InputRow>
<InfoNote>
If "Auto Broadcast Changes" is enabled, changes will be automatically broadcasted to connected peers without
requiring manual intervention. This requests peers to fetch this device's changes.
</InfoNote>
<ExtraItems title="Advanced Settings">
<InfoNote>
TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P
connections. In most cases, you can leave these fields blank.
</InfoNote>
<InfoNote warning>
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
server provider before using their services. Also your `network administrator` too. You should consider setting
up your own TURN server for your FQDN, if possible.
</InfoNote>
<InputRow label="TURN Server URLs (comma-separated)">
<textarea
name="p2p-turn-servers"
placeholder="turn:turn.example.com:3478,turn:turn.example.com:443"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_turnServers}
rows="5"
></textarea>
</InputRow>
<InputRow label="TURN Username">
<input
type="text"
name="p2p-turn-username"
placeholder="Enter TURN username"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
bind:value={syncSetting.P2P_turnUsername}
/>
</InputRow>
<InputRow label="TURN Credential">
<Password
name="p2p-turn-credential"
placeholder="Enter TURN credential"
bind:value={syncSetting.P2P_turnCredential}
/>
</InputRow>
</ExtraItems>
<InfoNote error visible={error !== ""}>
{error}
</InfoNote>
{#if processing}
Checking connection... Please wait.
{:else}
<UserDecisions>
<Decision title="Test Settings and Continue" important disabled={!canProceed} commit={() => checkAndCommit()} />
<Decision title="Continue anyway" commit={() => commit()} />
<Decision title="Cancel" commit={() => cancel()} />
</UserDecisions>
{/if}

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { configURIBase } from "../../../../common/types";
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
import Password from "@/lib/src/UI/components/Password.svelte";
import { onMount } from "svelte";
import { decryptString } from "../../../../lib/src/encryption/stringEncryption.ts";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog.ts";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
type Props = GuestDialogProps<ResultType, string>;
const { setResult, getInitialData }: Props = $props();
let setupURI = $state("");
let passphrase = $state("");
let error = $state("");
onMount(() => {
if (getInitialData) {
const initialURI = getInitialData();
if (initialURI) {
setupURI = initialURI;
}
}
});
const seemsValid = $derived.by(() => setupURI.startsWith(configURIBase));
async function processSetupURI() {
error = "";
if (!seemsValid) return;
if (!passphrase) {
error = "Passphrase is required.";
return;
}
try {
const settingPieces = setupURI.substring(configURIBase.length);
const encodedConfig = decodeURIComponent(settingPieces);
const newConf = (await JSON.parse(
await decryptString(encodedConfig, passphrase)
)) as ObsidianLiveSyncSettings;
setResult(newConf);
// Logger("Settings imported successfully", LOG_LEVEL_NOTICE);
return;
} catch (e) {
error = "Failed to parse Setup-URI.";
return;
}
}
async function canProceed() {
return (await processSetupURI()) ?? false;
}
</script>
<DialogHeader title="Enter Setup URI" />
<Guidance
>Please enter the Setup URI that was generated during server installation or on another device, along with the vault
passphrase.<br />
Note that you can generate a new Setup URI by running the "Copy settings as a new Setup URI" command in the command palette.</Guidance
>
<InputRow label="Setup-URI">
<input
type="text"
placeholder="obsidian://setuplivesync?settings=...."
bind:value={setupURI}
autocorrect="off"
autocapitalize="off"
spellcheck="false"
required
/>
</InputRow>
<InfoNote visible={seemsValid}>The Setup-URI is valid and ready to use.</InfoNote>
<InfoNote warning visible={!seemsValid && setupURI.trim() != ""}>
The Setup-URI does not appear to be valid. Please check that you have copied it correctly.
</InfoNote>
<InputRow label="Passphrase">
<Password placeholder="Enter your passphrase" bind:value={passphrase} required />
</InputRow>
<InfoNote error visible={error.trim() != ""}>
{error}
</InfoNote>
<UserDecisions>
<Decision
title="Test Settings and Continue"
important={true}
disabled={!canProceed}
commit={() => processSetupURI()}
/>
<Decision title="Cancel" commit={() => setResult(TYPE_CANCELLED)} />
</UserDecisions>

View File

@@ -0,0 +1,293 @@
import { requestToCouchDBWithCredentials } from "../../../../common/utils";
import { $msg } from "../../../../lib/src/common/i18n";
import { Logger } from "../../../../lib/src/common/logger";
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
import { parseHeaderValues } from "../../../../lib/src/common/utils";
import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
import { generateCredentialObject } from "../../../../lib/src/replication/httplib";
export type ResultMessage = { message: string; classes: string[] };
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
export type ResultOk = { message: string; result: "ok"; value?: any };
export type ResultError = { message: string; result: "error"; value: any; fixMessage: string; fix(): Promise<void> };
export type ConfigCheckResult = ResultOk | ResultError | ResultMessage | ResultErrorMessage;
/**
* Compares two version strings to determine if the baseVersion is greater than or equal to the version.
* @param baseVersion a.b.c format
* @param version a.b.c format
* @returns true if baseVersion is greater than or equal to version, false otherwise
*/
function isGreaterThanOrEqual(baseVersion: string, version: string) {
const versionParts = `${baseVersion}.0.0.0`.split(".");
const targetParts = version.split(".");
for (let i = 0; i < targetParts.length; i++) {
// compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a)
const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true });
if (result > 0) return true;
if (result < 0) return false;
}
return true;
}
/**
* Updates the remote CouchDB setting with the given key and value.
* @param setting Connection settings
* @param key setting key to update
* @param value setting value to update
* @returns true if the update was successful, false otherwise
*/
async function updateRemoteSetting(setting: ObsidianLiveSyncSettings, key: string, value: any) {
const customHeaders = parseHeaderValues(setting.couchDB_CustomHeaders);
const credential = generateCredentialObject(setting);
const res = await requestToCouchDBWithCredentials(
setting.couchDB_URI,
credential,
undefined,
key,
value,
undefined,
customHeaders
);
if (res.status == 200) {
return true;
} else {
return res.text || "Unknown error";
}
}
/**
* Checks the CouchDB configuration and returns the results.
* @param editingSettings
* @returns Array of ConfigCheckResult
*/
export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => {
const result = [] as ConfigCheckResult[];
const addMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, classes });
};
const addSuccess = (msg: string, value?: any) => {
result.push({ message: msg, result: "ok", value });
};
const _addError = (message: string, fixMessage: string, fix: () => Promise<void>, value?: any) => {
result.push({ message, result: "error", fixMessage, fix, value });
};
const addErrorMessage = (msg: string, classes: string[] = []) => {
result.push({ message: msg, result: "error", classes });
};
const addError = (message: string, fixMessage: string, key: string, expected: any) => {
_addError(message, fixMessage, async () => {
await updateRemoteSetting(editingSettings, key, expected);
});
};
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"));
try {
if (isCloudantURI(editingSettings.couchDB_URI)) {
addMessage($msg("obsidianLiveSyncSettingTab.logCannotUseCloudant"));
return result;
}
// Tip: Add log for cloudant as Logger($msg("obsidianLiveSyncSettingTab.logServerConfigurationCheck"));
const customHeaders = parseHeaderValues(editingSettings.couchDB_CustomHeaders);
const credential = generateCredentialObject(editingSettings);
const r = await requestToCouchDBWithCredentials(
editingSettings.couchDB_URI,
credential,
window.origin,
undefined,
undefined,
undefined,
customHeaders
);
const responseConfig = r.json;
addMessage($msg("obsidianLiveSyncSettingTab.msgNotice"), ["ob-btn-config-head"]);
addMessage($msg("obsidianLiveSyncSettingTab.msgIfConfigNotPersistent"), ["ob-btn-config-info"]);
addMessage($msg("obsidianLiveSyncSettingTab.msgConfigCheck"), ["ob-btn-config-head"]);
const serverBanner = r.headers["server"] ?? r.headers["Server"] ?? "unknown";
addMessage($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
// Compare version string with the target version.
// version must be a string like "3.2.1" or "3.10.2", and must be two or three parts.
// Admin check
// for database creation and deletion
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
addSuccess($msg("obsidianLiveSyncSettingTab.warnNoAdmin"));
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
}
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
// HTTP user-authorization check
if (responseConfig?.chttpd?.require_valid_user != "true") {
addError(
$msg("obsidianLiveSyncSettingTab.errRequireValidUser"),
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUser"),
"chttpd/require_valid_user",
"true"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okRequireValidUser"));
}
} else {
if (responseConfig?.chttpd_auth?.require_valid_user != "true") {
addError(
$msg("obsidianLiveSyncSettingTab.errRequireValidUserAuth"),
$msg("obsidianLiveSyncSettingTab.msgSetRequireValidUserAuth"),
"chttpd_auth/require_valid_user",
"true"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okRequireValidUserAuth"));
}
}
// HTTPD check
// Check Authentication header
if (!responseConfig?.httpd["WWW-Authenticate"]) {
addError(
$msg("obsidianLiveSyncSettingTab.errMissingWwwAuth"),
$msg("obsidianLiveSyncSettingTab.msgSetWwwAuth"),
"httpd/WWW-Authenticate",
'Basic realm="couchdb"'
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
}
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
if (responseConfig?.chttpd?.enable_cors != "true") {
addError(
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
$msg("obsidianLiveSyncSettingTab.msgEnableCorsChttpd"),
"chttpd/enable_cors",
"true"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okEnableCorsChttpd"));
}
} else {
if (responseConfig?.httpd?.enable_cors != "true") {
addError(
$msg("obsidianLiveSyncSettingTab.errEnableCors"),
$msg("obsidianLiveSyncSettingTab.msgEnableCors"),
"httpd/enable_cors",
"true"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okEnableCors"));
}
}
// If the server is not cloudant, configure request size
if (!isCloudantURI(editingSettings.couchDB_URI)) {
// REQUEST SIZE
if (Number(responseConfig?.chttpd?.max_http_request_size ?? 0) < 4294967296) {
addError(
$msg("obsidianLiveSyncSettingTab.errMaxRequestSize"),
$msg("obsidianLiveSyncSettingTab.msgSetMaxRequestSize"),
"chttpd/max_http_request_size",
"4294967296"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okMaxRequestSize"));
}
if (Number(responseConfig?.couchdb?.max_document_size ?? 0) < 50000000) {
addError(
$msg("obsidianLiveSyncSettingTab.errMaxDocumentSize"),
$msg("obsidianLiveSyncSettingTab.msgSetMaxDocSize"),
"couchdb/max_document_size",
"50000000"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okMaxDocumentSize"));
}
}
// CORS check
// checking connectivity for mobile
if (responseConfig?.cors?.credentials != "true") {
addError(
$msg("obsidianLiveSyncSettingTab.errCorsCredentials"),
$msg("obsidianLiveSyncSettingTab.msgSetCorsCredentials"),
"cors/credentials",
"true"
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsCredentials"));
}
const ConfiguredOrigins = ((responseConfig?.cors?.origins ?? "") + "").split(",");
if (
responseConfig?.cors?.origins == "*" ||
(ConfiguredOrigins.indexOf("app://obsidian.md") !== -1 &&
ConfiguredOrigins.indexOf("capacitor://localhost") !== -1 &&
ConfiguredOrigins.indexOf("http://localhost") !== -1)
) {
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsOrigins"));
} else {
const fixedValue = [
...new Set([
...ConfiguredOrigins.map((e) => e.trim()),
"app://obsidian.md",
"capacitor://localhost",
"http://localhost",
]),
].join(",");
addError(
$msg("obsidianLiveSyncSettingTab.errCorsOrigins"),
$msg("obsidianLiveSyncSettingTab.msgSetCorsOrigins"),
"cors/origins",
fixedValue
);
}
addMessage($msg("obsidianLiveSyncSettingTab.msgConnectionCheck"), ["ob-btn-config-head"]);
addMessage($msg("obsidianLiveSyncSettingTab.msgCurrentOrigin", { origin: window.location.origin }));
// Request header check
const origins = ["app://obsidian.md", "capacitor://localhost", "http://localhost"];
for (const org of origins) {
const rr = await requestToCouchDBWithCredentials(
editingSettings.couchDB_URI,
credential,
org,
undefined,
undefined,
undefined,
customHeaders
);
const responseHeaders = Object.fromEntries(
Object.entries(rr.headers).map((e) => {
e[0] = `${e[0]}`.toLowerCase();
return e;
})
);
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
if (responseHeaders["access-control-allow-credentials"] != "true") {
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsCredentialsForOrigin"));
}
if (responseHeaders["access-control-allow-origin"] != org) {
addErrorMessage(
$msg("obsidianLiveSyncSettingTab.warnCorsOriginUnmatched", {
from: origin,
to: responseHeaders["access-control-allow-origin"],
})
);
} else {
addSuccess($msg("obsidianLiveSyncSettingTab.okCorsOriginMatched"));
}
}
addMessage($msg("obsidianLiveSyncSettingTab.msgDone"), ["ob-btn-config-head"]);
addMessage($msg("obsidianLiveSyncSettingTab.msgConnectionProxyNote"), ["ob-btn-config-info"]);
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"));
} catch (ex: any) {
if (ex?.status == 401) {
addErrorMessage($msg("obsidianLiveSyncSettingTab.errAccessForbidden"));
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCannotContinueTest"));
addMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigDone"));
} else {
addErrorMessage($msg("obsidianLiveSyncSettingTab.logCheckingConfigFailed"));
Logger(ex);
}
}
return result;
};

View File

@@ -48,7 +48,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
}
if (!(await this.core.services.appLifecycle.onFirstInitialise())) return false;
// await this.core.$$realizeSettingSyncMode();
await this.services.setting.onRealiseSetting();
await this.services.setting.realiseSetting();
fireAndForget(async () => {
this._log($msg("moduleLiveSyncMain.logAdditionalSafetyScan"), LOG_LEVEL_VERBOSE);
if (!(await this.services.appLifecycle.onScanningStartupIssues())) {
@@ -67,7 +67,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
});
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
fireAndForget(() => this.core.services.setting.onRealiseSetting());
fireAndForget(() => this.core.services.setting.realiseSetting());
});
return Promise.resolve(true);
}

View File

@@ -14,10 +14,17 @@ import {
InjectableVaultService,
} from "../../lib/src/services/InjectableServices.ts";
import { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
import { ConfigServiceBrowserCompat } from "../../lib/src/services/Services.ts";
import type ObsidianLiveSyncPlugin from "../../main.ts";
import { ObsidianUIService } from "./ObsidianUIService.ts";
// All Services will be migrated to be based on Plain Services, not Injectable Services.
// This is a migration step.
export class ObsidianAPIService extends InjectableAPIService {}
export class ObsidianAPIService extends InjectableAPIService {
getPlatform(): string {
return "obsidian";
}
}
export class ObsidianPathService extends InjectablePathService {}
export class ObsidianDatabaseService extends InjectableDatabaseService {}
@@ -42,6 +49,8 @@ export class ObsidianVaultService extends InjectableVaultService {}
// InjectableTestService
export class ObsidianTestService extends InjectableTestService {}
export class ObsidianConfigService extends ConfigServiceBrowserCompat {}
// InjectableServiceHub
export class ObsidianServiceHub extends InjectableServiceHub {
@@ -73,4 +82,14 @@ export class ObsidianServiceHub extends InjectableServiceHub {
);
protected _vault: ObsidianVaultService = new ObsidianVaultService(this._serviceBackend, this._throughHole);
protected _test: ObsidianTestService = new ObsidianTestService(this._serviceBackend, this._throughHole);
private _plugin: ObsidianLiveSyncPlugin;
constructor(plugin: ObsidianLiveSyncPlugin) {
const config = new ObsidianConfigService();
super({
ui: new ObsidianUIService(plugin),
config: config,
});
this._plugin = plugin;
}
}

View File

@@ -0,0 +1,40 @@
import { UIService } from "../../lib/src/services/Services";
import type ObsidianLiveSyncPlugin from "../../main";
import { SvelteDialogManager } from "../features/SetupWizard/ObsidianSvelteDialog";
import DialogueToCopy from "../../lib/src/UI/dialogues/DialogueToCopy.svelte";
export class ObsidianUIService extends UIService {
private _dialogManager: SvelteDialogManager;
private _plugin: ObsidianLiveSyncPlugin;
get dialogManager() {
return this._dialogManager;
}
constructor(plugin: ObsidianLiveSyncPlugin) {
super();
this._dialogManager = new SvelteDialogManager(plugin);
this._plugin = plugin;
}
async promptCopyToClipboard(title: string, value: string): Promise<boolean> {
const param = {
title: title,
dataToCopy: value,
};
const result = await this._dialogManager.open(DialogueToCopy, param);
if (result !== "ok") {
return false;
}
return true;
}
showMarkdownDialog<T extends string[]>(
title: string,
contentMD: string,
buttons: T,
defaultAction?: (typeof buttons)[number]
): Promise<(typeof buttons)[number] | false> {
return this._plugin.confirm.askSelectStringDialogue(contentMD, buttons, {
title,
defaultAction: defaultAction ?? buttons[0],
timeout: 0,
});
}
}

View File

@@ -393,13 +393,13 @@ span.ls-mark-cr::after {
div.workspace-leaf-content[data-type=bases] .livesync-status {
top: calc(var(--bases-header-height) + var(--header-height));
padding: 5px;
padding-right:18px;
padding-right: 18px;
}
.is-mobile div.workspace-leaf-content[data-type=bases] .livesync-status {
top: calc(var(--bases-header-height) + var(--view-header-height));
padding: 6px;
padding-right:18px;
padding-right: 18px;
}
.livesync-status div {
@@ -414,12 +414,19 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
}
.livesync-status div.livesync-status-messagearea {
.livesync-status div.livesync-status-messagearea:empty {
display: none;
}
.livesync-status div.livesync-status-messagearea:not(:empty) {
opacity: 0.6;
color: var(--text-on-accent);
background: var(--background-modifier-error);
border: 1px solid var(--background-modifier-error);
background-color: rgba(var(--background-modifier-error-rgb), 0.2);
-webkit-filter: unset;
filter: unset;
width: fit-content;
margin-left: auto;
}
@@ -444,6 +451,10 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
padding: 0.5em 1.0em;
}
.active-pane .sls-setting-panel-title {
border: 1px solid var(--interactive-accent);
}
.sls-dialogue-note-wrapper {
display: flex;
justify-content: flex-end;

View File

@@ -13,6 +13,7 @@
"importHelpers": false,
"alwaysStrict": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"lib": ["es2018", "DOM", "ES5", "ES6", "ES7", "es2019.array", "ES2021.WeakRef", "ES2020.BigInt", "ESNext.Intl"],
"strictBindCallApply": true,

View File

@@ -4,126 +4,152 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## 0.25.21
## 0.25.25
13th October, 2025
This release including 0.25.21.beta1 and 0.25.21.beta2.
Apologies for taking a little time. I was seriously tackling this.
(Of course, being caught up in an unfamiliar structure due to personnel changes on my workplace played a part, but fortunately I have returned to a place where I can do research and development rather than production. Completely beside the point, though).
Now then, this time, moving away from 'convention over configuration', I have changed to a mechanism for manually binding events. This makes it much easier to leverage IDE assistance.
And, also, we are ready to separate `Features` and `APIs` from `Module`. Features are still in the module, but APIs will be moved to a Service layer. This will make it easier to maintain and extend the codebase in the future.
If you have found any issues, please let me know. I am now on the following:
- GitHub [Issues](https://github.com/vrtmrz/obsidian-livesync/issues) Excellent! May the other contributors will help you too.
- Twitter [@vorotamoroz](https://twitter.com/vorotamoroz) Quickest!
- Matrix [@vrtmrz:matrix.org](https://matrix.to/#/@vrtmrz:matrix.org) Also quick, and if you need to keep it private!
I am creating rooms too, but I'm struggling to figure out how to use them effectively because I cannot tell the difference of use-case between them and discussions. However, if you want to use Discord, this is a answer; We should on E2E encrypted platform.
## 0.25.21.beta2
8th October, 2025
06th November, 2025
### Fixed
- Fixed wrong event type bindings (which caused some events not to be handled correctly).
- Fixed detected a timing issue in StorageEventManager
- When multiple events for the same file are fired in quick succession, metadata has been kept older information. This induces unexpected wrong notifications and write prevention.
#### JWT Authentication
## 0.25.21.beta1
- Now we can use JWT Authentication ES512 correctly (#742).
- Several misdirections in the Setting dialogues have been fixed (i.e., seconds and minutes confusion...).
- The key area in the Setting dialogue has been enlarged and accepts newlines correctly.
- Caching of JWT tokens now works correctly
- Tokens are now cached and reused until they expire.
- They will be kept until 10% of the expiration duration is remaining or 10 seconds, whichever is longer (but at a maximum of 1 minute).
- JWT settings are now correctly displayed on the Setting dialogue.
6th October, 2025
And, tips about JWT Authentication on CouchDB have been added to the documentation (docs/tips/jwt-on-couchdb.md).
### Refactored
#### Other fixes
- Event handling now does not rely on 'convention over configuration'.
- Services.ts now have a proper event handler registration system.
## 0.25.20
26th September, 2025
### Fixed
- Chunk fetching no longer reports errors when the fetched chunk could not be saved (#710).
- Just using the fetched chunk temporarily.
- Chunk fetching reports errors when the fetched chunk is surely corrupted (#710, #712).
- It no longer detects files that the plug-in has modified.
- It may reduce unnecessary file comparisons and unexpected file states.
- Receiving non-latest revisions no longer causes unexpected overwrites.
- On receiving revisions that made conflicting changes, we are still able to handle them.
### Improved
- Now checking the remote database configuration respecting the CouchDB version (#714).
- No longer duplicated message notifications are shown when a connection to the remote server fails.
- Instead, a single notification is shown, and it will be kept on the notification area inside the editor until the situation is resolved.
- The notification area is no longer imposing, distracting, and overwhelming.
- With a pale background, but bordered and with icons.
## 0.25.19
## 0.25.24
18th September, 2025
04th November, 2025
### Improved
(Beta release notes have been consolidated to this note).
- Now encoding/decoding for chunk data and encryption/decryption are performed in native functions (if they were available).
- This uses Uint8Array.fromBase64 and Uint8Array.toBase64, which are natively available in iOS 18.2+ and Android with Chrome 140+.
- In Android, WebView is by default updated with Chrome, so it should be available in most cases.
- Note that this is not available in Desktop yet (due to being based on Electron). We are staying tuned for future updates.
- This realised by an external(?) package [octagonal-wheels](https://github.com/vrtmrz/octagonal-wheels). Therefore, this update only updates the dependency.
### Guidance and UI improvements!
## 0.25.18
Since several issues were pointed out, our setup procedure had been quite `system-oriented`. This is not good for users. Therefore, I have changed the procedure to be more `goal-oriented`. I have made extensive use of Svelte, resulting in a very straightforward setup.
While I would like to accelerate documentation and i18n adoption, I do not want to confuse everyone who's already working on it. Therefore, I have decided to release a Beta version at this stage. Significant changes are not expected from this point onward, so I will proceed to stabilise the codebase. (However, this is significant).
17th September, 2025
### TURN server support and important notice
TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P
connections. In most cases, you do not need to set up a TURN server.
Using public TURN servers may have privacy implications, as your data will be relayed through third-party
servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN
server provider before using their services. Also your `network administrator` too. You should consider setting
up your own TURN server for your FQDN, if possible.
### New features
- We can use the TURN server for P2P connections now.
### Fixed
- Property encryption detection now works correctly (On Self-hosted LiveSync, it was not broken, but as a library, it was not working correctly).
- Initialising the chunk splitter is now surely performed.
- DirectFileManipulator now works fine (as a library)
- Old `DirectFileManipulatorV1` is now removed.
- P2P Replication got more robust and stable.
- Update [Trystero](https://github.com/dmotz/trystero) to the official v0.22.0!
- Fixed a bug that caused P2P connections to drop or (unwanted reconnection to the relay server) unexpectedly in some environments.
- Now, the connection status is more accurately reported.
- While in the background, the connection to the signalling server is now disconnected to save resources.
- When returning to the foreground, it will not reconnect automatically for safety. Please reconnect manually.
- All connection configurations should be edited in each dedicated dialogue now.
- No longer will larger files create chunks during preparing `Reset Synchronisation on This Device`.
- Now hidden file synchronisation respects the filters correctly (#631, #735)
- And `ignore-files` settings are also respected and surely read during the start-up.
### Refactored
### Behaviour changes
- Removed some unnecessary intermediate files.
- The setup wizard is now more `goal-oriented`. Brand-new screens are introduced.
- `Fetch everything` and `Rebuild everything` are now `Reset Synchronisation on This Device` and `Overwrite Server Data with This Device's Files`.
- Remote configuration and E2EE settings are now separated into each modal dialogue.
- Remote configuration is now more straightforward. And if we need the rebuild (No... `Overwrite Server Data with This Device's Files`), it is now clearly indicated.
- Peer-to-Peer settings are also separated into their own modal dialogue (still in progress, and we need to open a P2P pane, still).
- Setup-URI, and Report for the Issue are now not copied to the clipboard automatically. Instead, there are copy-dialogue and buttons to copy them explicitly.
- This is to avoid confusion for users who do not want to use these features.
- No longer optional features are introduced during the setup, or `Reset Synchronisation on This Device`, `Overwrite Server Data with This Device's Files`.
- This is to avoid confusion for users who do not want to use these features. Instead, we will be informed that optional features are available after the setup is completed.
- We cannot perform `Fetch everything` and `Rebuild everything` (Removed, so the old name) without restarting Obsidian now.
## 0.25.17
### Miscellaneous
16th September, 2025
- Setup QR Code generation is separated into a src/lib/src/API/processSetting.ts file. Please use it as a subrepository if you want to generate QR codes in your own application.
- Setup-URI is also separated into a src/lib/src/API/processSetting.ts
- Some direct access to web APIs is now wrapped into the services layer.
### Dependency updates
- Many dependencies are updated. Please see `package.json`.
- This is the hardest part of this update. I read most of the changes in the dependencies. If you find any extra information, please let me know.
- As upgrading TypeScript, Fixed many UInt8Array<ArrayBuffer> and Uint8Array type mismatches.
-
### Breaking changes
- Sending configuration via Peer-to-Peer connection is not compatible with older versions.
- Please upgrade all devices to v0.25.24.beta1 or later to use this feature again.
- This is due to security improvements in the encryption scheme.
## 0.25.23
26th October, 2025
The next version we are preparing (you know that as 0.25.23.beta1) is now still on beta, resulting in this rather unfortunate versioning situation. Apologies for the confusion. The next v0.25.23.beta2 will be v0.25.24.beta1. In other words, this is a v0.25.22.patch-1 actually, but possibly not allowed by Obsidian's rule.
(Perhaps we ought to declare 1.0.0 with a little more confidence. The current minor part has been effectively a major one for a long time. If it were 1.22.1 and 1.23.0.beta1, no confusion ).
### Fixed
- No longer information-level logs have produced during toggling `Show only notifications` in the settings (#708).
- Ignoring filters for Hidden file sync now works correctly (#709).
- We are now able to enable optional features correctly again (#732).
- No longer oversized files have been processed, furthermore.
### Refactored
- Before creating a chunk, the file is verified as the target.
- The behaviour upon receiving replication has been changed as follows:
- If the remote file is oversized, it is ignored.
- If not, but while the local file is oversized, it is also ignored.
- Removed some unnecessary intermediate files.
- We are now able to enable optional features correctly again (#732).
- No longer oversized files have been processed, furthermore.
- Before creating a chunk, the file is verified as the target.
- The behaviour upon receiving replication has been changed as follows:
- If the remote file is oversized, it is ignored.
- If not, but while the local file is oversized, it is also ignored.
## 0.25.16
## 0.25.22
4th September, 2025
### Improved
- Improved connectivity for P2P connections
- The connection to the signalling server can now be disconnected while in the background or when explicitly disconnected.
- These features use a patch that has not been incorporated upstream.
- This patch is available at [vrtmrz/trystero](https://github.com/vrtmrz/trystero).
## 0.25.15
3rd September, 2025
### Improved
- Now we can configure `forcePathStyle` for bucket synchronisation (#707).
## 0.25.14
2nd September, 2025
15th October, 2025
### Fixed
- Opening IndexedDB handling has been ensured.
- Migration check of corrupted files detection has been fixed.
- Now informs us about conflicted files as non-recoverable, but noted so.
- No longer errors on not-found files.
- Fixed a bug that caused wrong event bindings and flag inversion (#727)
- This caused following issues:
- In some cases, settings changes were not applied or saved correctly.
- Automatic synchronisation did not begin correctly.
### Improved
- Too large diffs are not shown in the file comparison view, due to performance reasons.
### Notes
- The checking algorithm implemented in 0.25.20 is also raised as PR (#237). And completely I merged it manually.
- Sorry for lacking merging this PR, and let me say thanks to the great contribution, @bioluks !
- Known issues:
- Sync on Editor save seems not to work correctly in some cases.
- I am investigating this issue. If you have any information, please let me know.
Older notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).

View File

@@ -10,6 +10,129 @@ As a result, this is the first time in a while that forward compatibility has be
---
## 0.25.21
13th October, 2025
This release including 0.25.21.beta1 and 0.25.21.beta2.
Apologies for taking a little time. I was seriously tackling this.
(Of course, being caught up in an unfamiliar structure due to personnel changes on my workplace played a part, but fortunately I have returned to a place where I can do research and development rather than production. Completely beside the point, though).
Now then, this time, moving away from 'convention over configuration', I have changed to a mechanism for manually binding events. This makes it much easier to leverage IDE assistance.
And, also, we are ready to separate `Features` and `APIs` from `Module`. Features are still in the module, but APIs will be moved to a Service layer. This will make it easier to maintain and extend the codebase in the future.
If you have found any issues, please let me know. I am now on the following:
- GitHub [Issues](https://github.com/vrtmrz/obsidian-livesync/issues) Excellent! May the other contributors will help you too.
- Twitter [@vorotamoroz](https://twitter.com/vorotamoroz) Quickest!
- Matrix [@vrtmrz:matrix.org](https://matrix.to/#/@vrtmrz:matrix.org) Also quick, and if you need to keep it private!
I am creating rooms too, but I'm struggling to figure out how to use them effectively because I cannot tell the difference of use-case between them and discussions. However, if you want to use Discord, this is a answer; We should on E2E encrypted platform.
## 0.25.21.beta2
8th October, 2025
### Fixed
- Fixed wrong event type bindings (which caused some events not to be handled correctly).
- Fixed detected a timing issue in StorageEventManager
- When multiple events for the same file are fired in quick succession, metadata has been kept older information. This induces unexpected wrong notifications and write prevention.
## 0.25.21.beta1
6th October, 2025
### Refactored
- Event handling now does not rely on 'convention over configuration'.
- Services.ts now have a proper event handler registration system.
## 0.25.20
26th September, 2025
### Fixed
- Chunk fetching no longer reports errors when the fetched chunk could not be saved (#710).
- Just using the fetched chunk temporarily.
- Chunk fetching reports errors when the fetched chunk is surely corrupted (#710, #712).
- It no longer detects files that the plug-in has modified.
- It may reduce unnecessary file comparisons and unexpected file states.
### Improved
- Now checking the remote database configuration respecting the CouchDB version (#714).
## 0.25.19
18th September, 2025
### Improved
- Now encoding/decoding for chunk data and encryption/decryption are performed in native functions (if they were available).
- This uses Uint8Array.fromBase64 and Uint8Array.toBase64, which are natively available in iOS 18.2+ and Android with Chrome 140+.
- In Android, WebView is by default updated with Chrome, so it should be available in most cases.
- Note that this is not available in Desktop yet (due to being based on Electron). We are staying tuned for future updates.
- This realised by an external(?) package [octagonal-wheels](https://github.com/vrtmrz/octagonal-wheels). Therefore, this update only updates the dependency.
## 0.25.18
17th September, 2025
### Fixed
- Property encryption detection now works correctly (On Self-hosted LiveSync, it was not broken, but as a library, it was not working correctly).
- Initialising the chunk splitter is now surely performed.
- DirectFileManipulator now works fine (as a library)
- Old `DirectFileManipulatorV1` is now removed.
### Refactored
- Removed some unnecessary intermediate files.
## 0.25.17
16th September, 2025
### Fixed
- No longer information-level logs have produced during toggling `Show only notifications` in the settings (#708).
- Ignoring filters for Hidden file sync now works correctly (#709).
### Refactored
- Removed some unnecessary intermediate files.
## 0.25.16
4th September, 2025
### Improved
- Improved connectivity for P2P connections
- The connection to the signalling server can now be disconnected while in the background or when explicitly disconnected.
- These features use a patch that has not been incorporated upstream.
- This patch is available at [vrtmrz/trystero](https://github.com/vrtmrz/trystero).
## 0.25.15
3rd September, 2025
### Improved
- Now we can configure `forcePathStyle` for bucket synchronisation (#707).
## 0.25.14
2nd September, 2025
### Fixed
- Opening IndexedDB handling has been ensured.
- Migration check of corrupted files detection has been fixed.
- Now informs us about conflicted files as non-recoverable, but noted so.
- No longer errors on not-found files.
## 0.25.13
1st September, 2025

View File

@@ -12,18 +12,22 @@ if [[ -z "$password" ]]; then
echo "ERROR: Password missing"
exit 1
fi
if [[ -z "$node" ]]; then
echo "INFO: defaulting to _local"
node=_local
fi
echo "-- Configuring CouchDB by REST APIs... -->"
until (curl -X POST "${hostname}/_cluster_setup" -H "Content-Type: application/json" -d "{\"action\":\"enable_single_node\",\"username\":\"${username}\",\"password\":\"${password}\",\"bind_address\":\"0.0.0.0\",\"port\":5984,\"singlenode\":true}" --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/nonode@nohost/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done
until (curl -X PUT "${hostname}/_node/${node}/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${username}:${password}"); do sleep 5; done
echo "<-- Configuring CouchDB by REST APIs Done!"
echo "<-- Configuring CouchDB by REST APIs Done!"