Compare commits

..

76 Commits

Author SHA1 Message Date
vorotamoroz
3034af8d69 bump again 2026-05-20 11:37:51 +01:00
vorotamoroz
da3020bd45 fixed: fix auto-correction mismatch 2026-05-20 11:34:28 +01:00
vorotamoroz
ce232c1002 bump 2026-05-20 11:14:41 +01:00
vorotamoroz
0e13926400 fixed: update lib
I should do this with #919
2026-05-20 11:10:43 +01:00
vorotamoroz
fab7ec996a Merge pull request #919 from vrtmrz:feat_cli_remote_select
feat: add CLI commands to handle multiple remote configuration
2026-05-20 19:05:49 +09:00
vorotamoroz
88e22f99c5 Merge pull request #917 from vrtmrz:feat_tweak_auto_adjust
feat: implement auto-accept compatible tweak
2026-05-20 19:02:58 +09:00
vorotamoroz
83cbabf06f Merge branch 'main' into feat_tweak_auto_adjust 2026-05-20 11:01:51 +01:00
vorotamoroz
5e8d3b8f02 Update lib 2026-05-20 11:00:38 +01:00
vorotamoroz
1167b41340 feat: add CLI commands to handle multiple remote configuration 2026-05-20 05:10:42 +01:00
vorotamoroz
67da3964e5 Merge pull request #918 from vrtmrz/v0_25_65
V0.25.65
2026-05-19 20:27:34 +09:00
vorotamoroz
45ebc7eb6b Update the doc and issue report, and bump 2026-05-19 12:17:26 +01:00
vorotamoroz
cc5ead68bc minor layout fix 2026-05-19 11:59:54 +01:00
vorotamoroz
9b9e4f22f3 Merge pull request #916 from vrtmrz:fix_866
fixed: Now Chunk Splitter: `V3: Fine Deduplication`
2026-05-19 19:46:08 +09:00
vorotamoroz
7823f46053 Merge branch 'main' into fix_866 2026-05-19 11:45:41 +01:00
vorotamoroz
d6d8e548b3 Merge pull request #915 from vrtmrz/improve_log_feature
Improve log features
2026-05-19 19:40:28 +09:00
vorotamoroz
44b1ed7610 Merge branch 'main' into improve_log_feature 2026-05-19 11:40:14 +01:00
vorotamoroz
5786da5534 merged 2026-05-19 11:38:05 +01:00
vorotamoroz
042a80dd44 Merge pull request #914 from vrtmrz/fix_ios_resume
fix: Fix an issue about resuming from background on iOS (#888).
2026-05-19 19:34:49 +09:00
vorotamoroz
ee30f6cd6d fixed: Now Chunk Splitter: V3: Fine Deduplication is working fine again (#866). 2026-05-19 11:26:35 +01:00
vorotamoroz
977a300808 fix grammatical error 2026-05-19 04:10:39 +01:00
vorotamoroz
a392ccab6a - Improved an error verbosity on concurrent processing on start-up process.
- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`).
- Updating logs is now debounced to avoid excessive updates during rapid log generation.
- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue.
2026-05-19 04:09:04 +01:00
vorotamoroz
dfdfa5383b grammatical fix 2026-05-19 01:55:10 +01:00
vorotamoroz
a08294ab16 fix: Fix an issue about resuming from background on iOS (#888). 2026-05-18 12:30:03 +01:00
vorotamoroz
e8c33a0d6a feat: implement auto-accept compatible tweak setting and enhance mismatch resolution logic 2026-05-18 11:21:53 +01:00
vorotamoroz
d6bf453a6d Merge branch 'main' of https://github.com/vrtmrz/obsidian-livesync 2026-05-18 11:07:43 +01:00
vorotamoroz
e80cdc2dae remove unused files 2026-05-18 08:47:03 +01:00
vorotamoroz
60780678fd Merge pull request #903 from Joysimple/cli-docker
bugfix: Add package-lock.json into docker build
2026-05-18 12:38:12 +09:00
vorotamoroz
273e7a2b63 Merge pull request #909 from vrtmrz/p2p_config_selectable
P2p config selectable (a.k.a. v0.25.64)
2026-05-17 13:24:40 +09:00
vorotamoroz
2572c54744 Track main 2026-05-17 13:17:09 +09:00
vorotamoroz
6a7c987985 bump 2026-05-17 13:13:21 +09:00
vorotamoroz
6ef866a77c P2P: Enhance status pane and card with active remote selection and replication features
- Added active P2P remote selector and creation option in the status pane.
- Introduced immediate replication action for accepted peers.
- Updated status control icons for clarity.
- Display stable Room ID suffix above Peer ID in the status card.
- Implemented dedicated active remote configuration for P2P features.
- Added migration support for P2P active remote selection.
- Improved unit test coverage for P2P settings.
2026-05-17 13:08:51 +09:00
vorotamoroz
eea26dee74 Update about P2P 2026-05-17 02:35:20 +09:00
vorotamoroz
ee24fe8c24 Merge pull request #904 from vrtmrz/enhance_fix_p2p
Fixed: fixed P2P bugs and and implement new UI
2026-05-17 02:32:50 +09:00
vorotamoroz
9d9364af36 Fix updates.md 2026-05-17 02:24:58 +09:00
vorotamoroz
83228e2077 Fix P2P replicator creation and enhance error handling in synchronization functions 2026-05-17 02:23:58 +09:00
vorotamoroz
a379b5bd78 bump 2026-05-17 01:40:50 +09:00
vorotamoroz
4ed1749652 Enhance P2P synchronization features and UI improvements 2026-05-17 01:36:09 +09:00
vorotamoroz
9a90256a8a Enhance P2P synchronization features and UI improvements 2026-05-16 23:50:08 +09:00
vorotamoroz
f0628a0d2c Improve UI 2026-05-16 23:09:11 +09:00
vorotamoroz
d5e2f57781 Fixed: fixed P2P bugs and and implement new UI 2026-05-15 10:18:53 +01:00
Nikolay Sokolov
02673a1631 bugfix: Add package-lock.json into docker build 2026-05-14 23:17:37 -07:00
vorotamoroz
91c9746886 Merge pull request #900 from vrtmrz/v0_25_62
Releasing v0.25.62
2026-05-14 20:15:02 +09:00
vorotamoroz
75b44b1636 bump 2026-05-14 09:40:12 +00:00
vorotamoroz
f1fe48c1ee add version-bump 2026-05-14 09:35:53 +00:00
vorotamoroz
437e7c0d9c Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking 2026-05-14 09:32:34 +00:00
vorotamoroz
5ffa7ec7ee Merge pull request #897 from vrtmrz/v0_25_61
Releaseing v0.25.61
2026-05-13 23:06:57 +09:00
vorotamoroz
a1859f5d2e bump 2026-05-13 14:44:38 +01:00
vorotamoroz
785af8cb8f Merge pull request #896 from vrtmrz/address_community_review
Address community review
2026-05-13 22:29:37 +09:00
vorotamoroz
06e1f4aa4a Update subrepo pointer 2026-05-13 14:25:49 +01:00
vorotamoroz
767f22ce9c Merge branch 'address_community_review' of https://github.com/vrtmrz/obsidian-livesync into address_community_review 2026-05-13 14:16:51 +01:00
vorotamoroz
6a9bba702c chore: ran prettier 2026-05-13 14:10:56 +01:00
vorotamoroz
de2397dc3f Adding a rough DI 2026-05-13 14:10:55 +01:00
vorotamoroz
daaad9212e Fix package-lock 2026-05-13 14:10:54 +01:00
vorotamoroz
a6891374a1 chore: Package modernise, update linter 2026-05-13 14:10:01 +01:00
vorotamoroz
b1cadf0549 prettify 2026-05-13 14:07:58 +01:00
vorotamoroz
95f40cc954 (chore): removing DOM Operation 2026-05-13 14:07:58 +01:00
vorotamoroz
8deaf123d6 Update eslint config to ignore file,
fix some type error on LiveSyncBaseCore
2026-05-13 14:06:51 +01:00
vorotamoroz
053813bffb Update for review once 2026-05-13 14:06:51 +01:00
vorotamoroz
cc7af03618 chore: Package modernise, update linter 2026-05-13 14:06:51 +01:00
vorotamoroz
a130e3700e prettify 2026-05-13 14:06:50 +01:00
vorotamoroz
0549e901b2 (chore): removing DOM Operation 2026-05-13 14:06:49 +01:00
vorotamoroz
e9afe06968 Merge pull request #895 from vrtmrz/update_lib
Update lib to fix P2P problems and merging contributions
2026-05-13 22:01:17 +09:00
vorotamoroz
37715d4c9f chore: ran prettier 2026-05-13 11:12:40 +00:00
vorotamoroz
106367fa41 Adding a rough DI 2026-05-13 11:09:04 +00:00
vorotamoroz
538130aa91 Fix package-lock 2026-05-13 11:36:01 +01:00
vorotamoroz
c9d0357fec Merge branch 'address_community_review' of https://github.com/vrtmrz/obsidian-livesync into address_community_review 2026-05-13 11:35:01 +01:00
vorotamoroz
d05c76da36 Update eslint config to ignore file,
fix some type error on LiveSyncBaseCore
2026-05-13 11:33:46 +01:00
vorotamoroz
d2eb6ecbaf Update for review once 2026-05-13 11:33:46 +01:00
vorotamoroz
25a6fde212 chore: Package modernise, update linter 2026-05-13 11:33:45 +01:00
vorotamoroz
e8f8b680ef prettify 2026-05-13 11:33:03 +01:00
vorotamoroz
6c30f2b863 (chore): removing DOM Operation 2026-05-13 11:33:03 +01:00
vorotamoroz
770d4af4a0 Update eslint config to ignore file,
fix some type error on LiveSyncBaseCore
2026-05-13 10:15:45 +01:00
vorotamoroz
3b311248cb Update for review once 2026-05-13 08:02:50 +01:00
vorotamoroz
5772811a45 chore: Package modernise, update linter 2026-05-13 04:40:32 +01:00
vorotamoroz
55529cd71e prettify 2026-05-13 03:58:08 +01:00
vorotamoroz
2e9b8b7b62 (chore): removing DOM Operation 2026-05-13 03:55:11 +01:00
69 changed files with 5292 additions and 1801 deletions

View File

@@ -53,11 +53,12 @@ The hatch report (below) includes version information. If you cannot provide the
- Self-hosted LiveSync version: <!-- e.g. 0.23.0 — find it in Obsidian Settings → Community Plugins --> - Self-hosted LiveSync version: <!-- e.g. 0.23.0 — find it in Obsidian Settings → Community Plugins -->
### Report from LiveSync ### Report and Logs from LiveSync
Open the `Hatch` pane in LiveSync settings and press `Make report`. Paste here or upload to [Gist](https://gist.github.com/) and share the link. Perform a `Generate full report for opening the issue with debug info` command and provide the generated report. This contains detailed information and recent 1000 log lines, which is very helpful for debugging. **PLEASE AMEND THE REPORT TO REMOVE ANY SENSITIVE INFORMATION BEFORE PASTING.**
If too large to paste here, upload to [Gist](https://gist.github.com/) and share the link.
<details> <details>
<summary>Report from hatch (primary)</summary> <summary>Report and Logs (primary)</summary>
``` ```
<!-- paste here or link to Gist --> <!-- paste here or link to Gist -->
@@ -65,29 +66,7 @@ Open the `Hatch` pane in LiveSync settings and press `Make report`. Paste here o
</details> </details>
<details> <details>
<summary>Report from hatch (if applicable)</summary> <summary>Report and Logs (if applicable)</summary>
```
<!-- paste here or link to Gist -->
```
</details>
### Plug-in log
Enable `Verbose Log` in General Settings first, then reproduce the issue and copy the log (tap the document box icon in the ribbon).
Paste here or upload to [Gist](https://gist.github.com/) and share the link.
<details>
<summary>Plug-in log (primary)</summary>
```
<!-- paste here or link to Gist -->
```
</details>
<details>
<summary>Plug-in log (if applicable)</summary>
``` ```
<!-- paste here or link to Gist --> <!-- paste here or link to Gist -->

2
.gitignore vendored
View File

@@ -29,3 +29,5 @@ cov_profile/**
coverage coverage
src/apps/cli/dist/* src/apps/cli/dist/*
_testdata/**
utils/bench/splitResults.csv

View File

@@ -4,7 +4,7 @@
Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation. Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation.
Additionally, it supports peer-to-peer synchronisation using WebRTC now (experimental), enabling you to synchronise your notes directly between devices without relying on a server. Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling you to synchronise your notes directly between devices without relying on a server. Documentations is available for [Peer-to-Peer Synchronisation](./docs/p2p_sync_updates_2026.md).
![obsidian_live_sync_demo](https://user-images.githubusercontent.com/45774780/137355323-f57a8b09-abf2-4501-836c-8cb7d2ff24a3.gif) ![obsidian_live_sync_demo](https://user-images.githubusercontent.com/45774780/137355323-f57a8b09-abf2-4501-836c-8cb7d2ff24a3.gif)

View File

@@ -0,0 +1,59 @@
# User Guide: Peer-to-Peer Synchronisation (2026 Edition)
Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates.
## 1. Core Concept: Server-less Freedom
P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom."
## 2. Setting Up via P2P Status Pane
You no longer need to navigate through complex menus. Simply open the **P2P Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon.
This opens the **P2P Setup** dialogue where you can configure the essentials:
- **Room ID:** A unique identifier for your synchronisation group.
- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase.
- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`).
Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network.
*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.*
## 3. Real-time Control
The status pane in the right sidebar provides granular control over your synchronisation:
- **Active P2P Remote (new):** P2P now has its own active remote selection, separate from the normal active remote for database replication. Use the combo box next to the cog icon to choose which P2P remote configuration is active for P2P features.
- **Create P2P Remote (new):** Use the **+** button to open the P2P setup dialogue and create a dedicated P2P remote configuration. This is recommended when no P2P active remote has been selected yet.
- **Selection required (new):** If no P2P active remote is selected, the pane asks for selection before P2P target-related changes are saved.
- **Signalling Status:** Shows if you are connected to the relay (🟢 Online).
- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit.
- **Replicate now (🔄):** Start immediate bidirectional replication with a visible peer (Pull, then Push).
- **Watch (🔔/🔕):** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience.
- **Sync target (🔗/⛓️‍💥):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔗, highlighted) or off (⛓️‍💥). This setting is persisted in your configuration.
## 4. Replication Dialogue
If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices.
Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync.
The status card now shows a stable **Room ID suffix** above **Peer ID**. The Room ID suffix is better for identifying your P2P group, while Peer ID may change between connections.
Two actions are available per peer:
- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers.
- **Start Sync & Close** — Starts the same bidirectional sync in the background and **immediately closes the dialogue**, so you can continue working without waiting.
## 5. Syncing with Registered Targets via Command Palette
You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI.
1. Open the **Command Palette** (`Ctrl/Cmd + P`).
2. Run **"P2P: Sync with targets"**.
This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly.
*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.*
## 6. Technical Improvements in 2026
- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plugin more stable across different platforms (Mobile, Desktop, and Web).
- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators.
- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes.

View File

@@ -255,14 +255,20 @@ It depends on Obsidian detects. May toggling `Detect all extensions` of
### I hope to report the issue, but you said you needs `Report`. How to make it? ### I hope to report the issue, but you said you needs `Report`. How to make it?
We can copy the report to the clipboard, by pressing the `Make report` button on We can copy the report to the clipboard, by performing
the `Hatch` pane. ![Screenshot](../images/hatch.png) `Generate full report for opening the issue with debug info` command!
### Where can I check the log? ### Where can I check the log?
We can launch the log pane by `Show log` on the command palette. And if you have We can launch the log pane by `Show log` on the command palette. And if you have
troubled something, please enable the `Verbose Log` on the `General Setting` troubled something, please enable the `Verbose Log` on the `General Setting`
pane. pane.
`Generate full report for opening the issue with debug info` command also contains
the recent 1000 log lines, which is very helpful for debugging. Full-report is
already set to the verbose level, so it contains all the logs without enabling the
`Verbose Log` toggle.
Let me note that please be sure to remove any sensitive information before sharing the report.
However, the logs would not be kept so long and cleared when restarted. If you However, the logs would not be kept so long and cleared when restarted. If you
want to check the logs, please enable `Write logs into the file` temporarily. want to check the logs, please enable `Write logs into the file` temporarily.

View File

@@ -2,7 +2,6 @@
import esbuild from "esbuild"; import esbuild from "esbuild";
import process from "process"; import process from "process";
import builtins from "builtin-modules";
import sveltePlugin from "esbuild-svelte"; import sveltePlugin from "esbuild-svelte";
import { sveltePreprocess } from "svelte-preprocess"; import { sveltePreprocess } from "svelte-preprocess";
import fs from "node:fs"; import fs from "node:fs";

View File

@@ -1,24 +1,11 @@
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import svelte from "eslint-plugin-svelte";
import _import from "eslint-plugin-import";
import { fixupPluginRules } from "@eslint/compat";
import tsParser from "@typescript-eslint/parser"; import tsParser from "@typescript-eslint/parser";
import path from "node:path"; import obsidianmd from "eslint-plugin-obsidianmd";
import { fileURLToPath } from "node:url"; import globals from "globals";
import js from "@eslint/js"; import { defineConfig, globalIgnores } from "eslint/config";
import { FlatCompat } from "@eslint/eslintrc"; import * as sveltePlugin from "eslint-plugin-svelte";
const __filename = fileURLToPath(import.meta.url); export default defineConfig([
const __dirname = path.dirname(__filename); globalIgnores([
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: [
"**/node_modules/*", "**/node_modules/*",
"**/jest.config.js", "**/jest.config.js",
"src/lib/coverage", "src/lib/coverage",
@@ -27,6 +14,7 @@ export default [
"**/tests.ts", "**/tests.ts",
"**/**test.ts", "**/**test.ts",
"**/**.test.ts", "**/**.test.ts",
"**/*.unit.spec.ts",
"**/esbuild.*.mjs", "**/esbuild.*.mjs",
"**/terser.*.mjs", "**/terser.*.mjs",
"**/node_modules", "**/node_modules",
@@ -44,60 +32,52 @@ export default [
"src/apps/**/*", "src/apps/**/*",
".prettierrc.*.mjs", ".prettierrc.*.mjs",
".prettierrc.mjs", ".prettierrc.mjs",
"*.config.mjs" "*.config.mjs",
], "src/apps/**/*",
}, "src/lib/src/services/implements/browser/**",
...compat.extends( "src/lib/src/services/implements/headless/**",
"eslint:recommended", "src/lib/src/API",
"plugin:@typescript-eslint/eslint-recommended", ]),
"plugin:@typescript-eslint/recommended" ...sveltePlugin.configs["flat/base"],
), ...obsidianmd.configs.recommended,
{ {
plugins: { files: ["**/*.ts"],
"@typescript-eslint": typescriptEslint,
svelte,
import: fixupPluginRules(_import),
},
languageOptions: { languageOptions: {
globals: { ...globals.browser },
parser: tsParser, parser: tsParser,
ecmaVersion: 5,
sourceType: "module",
parserOptions: { parserOptions: {
project: ["tsconfig.json"], project: "./tsconfig.json",
}, },
}, },
rules: { rules: {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "none",
},
],
"no-unused-labels": "off", "no-unused-labels": "off",
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
"require-await": "error", "require-await": "error",
"obsidianmd/rule-custom-message": "off", // Temporary
"obsidianmd/ui/sentence-case": "off", // Temporary
"@typescript-eslint/require-await": "warn", "@typescript-eslint/require-await": "warn",
"@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-floating-promises": "warn",
"no-async-promise-executor": "warn", "no-async-promise-executor": "warn",
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "error", "@typescript-eslint/no-unnecessary-type-assertion": "error",
"no-constant-condition": ["error", { checkLoops: false }],
"no-constant-condition": [ },
"error", },
{ {
checkLoops: false, files: ["**/*.svelte"],
}, languageOptions: {
], parserOptions: {
parser: tsParser,
}, },
}, },
]; rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"obsidianmd/no-plugin-as-component": "off", // Temporary
},
},
]);

View File

@@ -1,8 +1,8 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.25.60", "version": "0.25.67",
"minAppVersion": "0.9.12", "minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "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", "author": "vorotamoroz",
"authorUrl": "https://github.com/vrtmrz", "authorUrl": "https://github.com/vrtmrz",

1611
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "obsidian-livesync", "name": "obsidian-livesync",
"version": "0.25.60", "version": "0.25.67",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "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", "main": "main.js",
"type": "module", "type": "module",
@@ -54,15 +54,14 @@
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
"test:docker-all:stop": "npm run test:docker-all:down", "test:docker-all:stop": "npm run test:docker-all:down",
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh" "test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
"version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],
"author": "vorotamoroz", "author": "vorotamoroz",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@chialab/esbuild-plugin-worker": "^0.19.0", "@chialab/esbuild-plugin-worker": "^0.19.0",
"@eslint/compat": "^2.0.2",
"@eslint/eslintrc": "^3.3.4",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tsconfig/svelte": "^5.0.8", "@tsconfig/svelte": "^5.0.8",
@@ -84,18 +83,15 @@
"@vitest/browser": "^4.1.1", "@vitest/browser": "^4.1.1",
"@vitest/browser-playwright": "^4.1.1", "@vitest/browser-playwright": "^4.1.1",
"@vitest/coverage-v8": "^4.1.1", "@vitest/coverage-v8": "^4.1.1",
"builtin-modules": "5.0.0",
"dotenv": "^17.3.1",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"esbuild": "0.25.0", "esbuild": "0.25.0",
"esbuild-plugin-inline-worker": "^0.1.1", "esbuild-plugin-inline-worker": "^0.1.1",
"esbuild-svelte": "^0.9.4", "esbuild-svelte": "^0.9.4",
"eslint": "^9.39.3", "eslint": "^9.39.3",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-obsidianmd": "^0.3.0",
"eslint-plugin-svelte": "^3.15.0", "eslint-plugin-svelte": "^3.15.0",
"events": "^3.3.0", "events": "^3.3.0",
"glob": "^13.0.6", "globals": "^14.0.0",
"obsidian": "^1.12.3",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-load-config": "^6.0.1", "postcss-load-config": "^6.0.1",
@@ -116,6 +112,7 @@
"svelte-check": "^4.4.3", "svelte-check": "^4.4.3",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^6.0.3",
"terser": "^5.39.0", "terser": "^5.39.0",
"tinyglobby": "^0.2.15",
"transform-pouch": "^2.0.0", "transform-pouch": "^2.0.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
@@ -136,6 +133,7 @@
"@trystero-p2p/nostr": "^0.23.0", "@trystero-p2p/nostr": "^0.23.0",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"commander": "^14.0.3", "commander": "^14.0.3",
"obsidian": "^1.12.3",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"idb": "^8.0.3", "idb": "^8.0.3",

View File

@@ -1,4 +1,5 @@
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
import type PouchDB from "pouchdb-core";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types"; import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
import { __$checkInstanceBinding } from "./lib/src/dev/checks"; import { __$checkInstanceBinding } from "./lib/src/dev/checks";
@@ -123,7 +124,7 @@ export class LiveSyncBaseCore<
for (const module of this.modules) { for (const module of this.modules) {
if (module.constructor === constructor) return module as T; if (module.constructor === constructor) return module as T;
} }
throw new Error(`Module ${constructor} not found or not loaded.`); throw new Error(`Module ${constructor.name} not found or not loaded.`);
} }
/** /**
@@ -160,8 +161,10 @@ export class LiveSyncBaseCore<
module.onBindFunction(this, this.services); module.onBindFunction(this, this.services);
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not. __$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
} else { } else {
// module should not be never.
const moduleName = (module as unknown)?.constructor?.name ?? "unknown";
this.services.API.addLog( this.services.API.addLog(
`Module ${(module as any)?.constructor?.name ?? "unknown"} does not have onBindFunction, skipping binding.`, `Module ${moduleName} does not have onBindFunction, skipping binding.`,
LOG_LEVEL_INFO LOG_LEVEL_INFO
); );
} }

View File

@@ -60,7 +60,7 @@ RUN apt-get update \
WORKDIR /build WORKDIR /build
# Install workspace dependencies first (layer-cache friendly) # Install workspace dependencies first (layer-cache friendly)
COPY package.json ./ COPY package.json package-lock.json ./
RUN npm install RUN npm install
# Copy the full source tree and build the CLI bundle # Copy the full source tree and build the CLI bundle

View File

@@ -74,6 +74,12 @@ livesync-cli [database-path] [command] [args...]
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`. - `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
- `cat <src>`: Read a file from the database and write to stdout. - `cat <src>`: Read a file from the database and write to stdout.
- `put <dst>`: Read from stdin and write to the database path `<dst>`. - `put <dst>`: Read from stdin and write to the database path `<dst>`.
- `remote-add <name> <connstr>`: Add a remote configuration from a connection string.
- `remote-rm <remote-id>`: Remove a remote configuration by ID.
- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI).
- `remote-export <remote-id>`: Export the stored connection string by remote ID.
- `remote-set <remote-id> <connstr>`: Replace the stored connection string by remote ID.
- `remote-activate <remote-id>`: Activate a remote configuration by ID.
- `init-settings [file]`: Create a default settings file. - `init-settings [file]`: Create a default settings file.
### Examples ### Examples
@@ -252,6 +258,14 @@ livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm /
# Resolve conflict by keeping a specific revision # Resolve conflict by keeping a specific revision
livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef
# Add/list/activate/remove remote configurations
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db"
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret"
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123
``` ```
### Configuration ### Configuration

View File

@@ -32,10 +32,15 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, any>) {
settings.P2P_IsHeadless = true; settings.P2P_IsHeadless = true;
} }
function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): LiveSyncTrysteroReplicator { async function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
validateP2PSettings(core); validateP2PSettings(core);
const replicator = new LiveSyncTrysteroReplicator({ services: core.services }); const replicator = await core.services.replicator.getNewReplicator();
addP2PEventHandlers(replicator); if (!replicator) {
throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings.");
}
if (!(replicator instanceof LiveSyncTrysteroReplicator)) {
throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator.");
}
return replicator; return replicator;
} }
@@ -49,7 +54,7 @@ export async function collectPeers(
core: LiveSyncBaseCore<ServiceContext, any>, core: LiveSyncBaseCore<ServiceContext, any>,
timeoutSec: number timeoutSec: number
): Promise<CLIP2PPeer[]> { ): Promise<CLIP2PPeer[]> {
const replicator = createReplicator(core); const replicator = await createReplicator(core);
await replicator.open(); await replicator.open();
try { try {
await delay(timeoutSec * 1000); await delay(timeoutSec * 1000);
@@ -79,7 +84,7 @@ export async function syncWithPeer(
peerToken: string, peerToken: string,
timeoutSec: number timeoutSec: number
): Promise<CLIP2PPeer> { ): Promise<CLIP2PPeer> {
const replicator = createReplicator(core); const replicator = await createReplicator(core);
await replicator.open(); await replicator.open();
try { try {
const timeoutMs = timeoutSec * 1000; const timeoutMs = timeoutSec * 1000;
@@ -115,7 +120,7 @@ export async function syncWithPeer(
} }
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> { export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
const replicator = createReplicator(core); const replicator = await createReplicator(core);
await replicator.open(); await replicator.open();
return replicator; return replicator;
} }

View File

@@ -3,6 +3,8 @@ import * as path from "path";
import { decodeSettingsFromSetupURI } from "@lib/API/processSetting"; import { decodeSettingsFromSetupURI } from "@lib/API/processSetting";
import { configURIBase } from "@lib/common/models/shared.const"; import { configURIBase } from "@lib/common/models/shared.const";
import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types"; import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types";
import { ConnectionStringParser } from "@lib/common/ConnectionString";
import { activateRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
import { stripAllPrefixes } from "@lib/string_and_binary/path"; import { stripAllPrefixes } from "@lib/string_and_binary/path";
import type { CLICommandContext, CLIOptions } from "./types"; import type { CLICommandContext, CLIOptions } from "./types";
import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils"; import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils";
@@ -10,6 +12,10 @@ import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./
import { performFullScan } from "@lib/serviceFeatures/offlineScanner"; import { performFullScan } from "@lib/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
function redactConnectionString(uri: string): string {
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
}
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> { export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
const { databasePath, core, settingsPath } = context; const { databasePath, core, settingsPath } = context;
@@ -43,10 +49,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// 3. Re-enable sync. // 3. Re-enable sync.
const restoreSyncSettings = async () => { const restoreSyncSettings = async () => {
await core.services.setting.applyPartial({ await core.services.setting.applyPartial(
{
...context.originalSyncSettings, ...context.originalSyncSettings,
suspendFileWatching: false, suspendFileWatching: false,
}, true); },
true
);
// applySettings fires the full lifecycle: onSuspending → onResumed. // applySettings fires the full lifecycle: onSuspending → onResumed.
// ModuleReplicatorCouchDB starts continuous replication on onResumed // ModuleReplicatorCouchDB starts continuous replication on onResumed
// via fireAndForget. // via fireAndForget.
@@ -54,10 +63,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
// Lifecycle events (onSuspending) may re-enable suspension flags. // Lifecycle events (onSuspending) may re-enable suspension flags.
// Clear them explicitly after the lifecycle completes. applyPartial // Clear them explicitly after the lifecycle completes. applyPartial
// with true is a direct store write — it does not re-trigger lifecycle. // with true is a direct store write — it does not re-trigger lifecycle.
await core.services.setting.applyPartial({ await core.services.setting.applyPartial(
{
suspendFileWatching: false, suspendFileWatching: false,
suspendParseReplicationResult: false, suspendParseReplicationResult: false,
}, true); },
true
);
}; };
if (options.interval) { if (options.interval) {
log(`Polling mode: syncing every ${options.interval}s`); log(`Polling mode: syncing every ${options.interval}s`);
@@ -80,7 +92,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs); currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err); console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
if (consecutiveFailures >= 5) { if (consecutiveFailures >= 5) {
console.error(`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`); console.error(
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
);
} }
} }
pollTimer = setTimeout(poll, currentIntervalMs); pollTimer = setTimeout(poll, currentIntervalMs);
@@ -99,9 +113,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
log("LiveSync active"); log("LiveSync active");
const currentSettings = core.services.setting.currentSettings(); const currentSettings = core.services.setting.currentSettings();
if (!currentSettings.liveSync && !currentSettings.syncOnStart) { if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
console.error("[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " + console.error(
"[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " + "No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
"or use --interval for polling mode."); "or use --interval for polling mode."
);
} }
} }
@@ -459,5 +475,206 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
return await performFullScan(core as any, log, errorManager, false, true); return await performFullScan(core as any, log, errorManager, false, true);
} }
if (options.command === "remote-add") {
if (options.commandArgs.length < 2) {
throw new Error("remote-add requires two arguments: <name> <connstr>");
}
const name = options.commandArgs[0].trim();
const connectionString = options.commandArgs[1].trim();
if (!name) {
throw new Error("remote-add requires a non-empty name");
}
if (!connectionString) {
throw new Error("remote-add requires a non-empty connection string");
}
const parsed = ConnectionStringParser.parse(connectionString);
const canonicalUri = ConnectionStringParser.serialize(parsed);
const id = createRemoteConfigurationId();
let activated = false;
await core.services.setting.updateSettings((currentSettings) => {
currentSettings.remoteConfigurations ||= {};
currentSettings.remoteConfigurations[id] = {
id,
name,
uri: canonicalUri,
isEncrypted: false,
};
if (!currentSettings.activeConfigurationId) {
currentSettings.activeConfigurationId = id;
const applied = activateRemoteConfiguration(currentSettings, id);
activated = applied !== false;
}
return currentSettings;
}, true);
if (activated) {
await core.services.control.applySettings();
}
process.stdout.write(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`);
return true;
}
if (options.command === "remote-rm") {
if (options.commandArgs.length < 1) {
throw new Error("remote-rm requires one argument: <remote-id>");
}
const id = options.commandArgs[0].trim();
if (!id) {
throw new Error("remote-rm requires a non-empty remote-id");
}
const current = core.services.setting.currentSettings();
if (!current.remoteConfigurations?.[id]) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
let switchedActive = false;
await core.services.setting.updateSettings((currentSettings) => {
const configs = currentSettings.remoteConfigurations || {};
delete configs[id];
currentSettings.remoteConfigurations = configs;
if (currentSettings.activeConfigurationId === id) {
const nextActiveId = Object.keys(configs)[0] || "";
currentSettings.activeConfigurationId = nextActiveId;
switchedActive = nextActiveId !== "";
if (nextActiveId !== "") {
activateRemoteConfiguration(currentSettings, nextActiveId);
}
}
if (currentSettings.P2P_ActiveRemoteConfigurationId === id) {
currentSettings.P2P_ActiveRemoteConfigurationId = "";
}
return currentSettings;
}, true);
if (switchedActive) {
await core.services.control.applySettings();
}
console.error(`[Command] remote-rm ${id}`);
return true;
}
if (options.command === "remote-ls") {
const settings = core.services.setting.currentSettings();
const configs = Object.values(settings.remoteConfigurations || {});
configs.sort((a, b) => a.name.localeCompare(b.name));
if (configs.length === 0) {
process.stderr.write("[Info] No remote configurations found.\n");
return true;
}
const lines = configs.map((config) => {
const status = config.id === settings.activeConfigurationId ? "active" : "inactive";
return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`;
});
process.stdout.write(lines.join("\n") + "\n");
return true;
}
if (options.command === "remote-export") {
if (options.commandArgs.length < 1) {
throw new Error("remote-export requires one argument: <remote-id>");
}
const id = options.commandArgs[0].trim();
if (!id) {
throw new Error("remote-export requires a non-empty remote-id");
}
const config = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!config) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
process.stdout.write(`${config.uri}\n`);
return true;
}
if (options.command === "remote-set") {
if (options.commandArgs.length < 2) {
throw new Error("remote-set requires two arguments: <remote-id> <connstr>");
}
const id = options.commandArgs[0].trim();
const connectionString = options.commandArgs[1].trim();
if (!id) {
throw new Error("remote-set requires a non-empty remote-id");
}
if (!connectionString) {
throw new Error("remote-set requires a non-empty connection string");
}
const parsed = ConnectionStringParser.parse(connectionString);
const canonicalUri = ConnectionStringParser.serialize(parsed);
let switchedActive = false;
await core.services.setting.updateSettings((currentSettings) => {
const config = currentSettings.remoteConfigurations?.[id];
if (!config) {
return currentSettings;
}
config.uri = canonicalUri;
if (currentSettings.activeConfigurationId === id) {
const activated = activateRemoteConfiguration(currentSettings, id);
switchedActive = activated !== false;
if (activated) {
return activated;
}
}
return currentSettings;
}, true);
const updated = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!updated) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
if (switchedActive) {
await core.services.control.applySettings();
}
console.error(`[Command] remote-set ${id}`);
return true;
}
if (options.command === "remote-activate") {
if (options.commandArgs.length < 1) {
throw new Error("remote-activate requires one argument: <remote-id>");
}
const id = options.commandArgs[0].trim();
if (!id) {
throw new Error("remote-activate requires a non-empty remote-id");
}
let switched = false;
await core.services.setting.updateSettings((currentSettings) => {
const activated = activateRemoteConfiguration(currentSettings, id);
if (activated) {
switched = true;
return activated;
}
return currentSettings;
}, true);
if (!switched) {
process.stderr.write(`[Info] Failed to activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
console.error(`[Command] remote-activate ${id}`);
return true;
}
throw new Error(`Unsupported command: ${options.command}`); throw new Error(`Unsupported command: ${options.command}`);
} }

View File

@@ -1,12 +1,19 @@
import * as processSetting from "@lib/API/processSetting"; import * as processSetting from "@lib/API/processSetting";
import { ConnectionStringParser } from "@lib/common/ConnectionString";
import { configURIBase } from "@lib/common/models/shared.const"; import { configURIBase } from "@lib/common/models/shared.const";
import { DEFAULT_SETTINGS } from "@lib/common/types"; import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@lib/common/types";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand"; import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types"; import type { CLIOptions } from "./types";
import * as commandUtils from "./utils"; import * as commandUtils from "./utils";
function createCoreMock() { function createCoreMock() {
const liveSettings = {
...DEFAULT_SETTINGS,
remoteConfigurations: {},
activeConfigurationId: "",
P2P_ActiveRemoteConfigurationId: "",
} as any;
return { return {
services: { services: {
control: { control: {
@@ -16,6 +23,10 @@ function createCoreMock() {
setting: { setting: {
applyExternalSettings: vi.fn(async () => {}), applyExternalSettings: vi.fn(async () => {}),
applyPartial: vi.fn(async () => {}), applyPartial: vi.fn(async () => {}),
currentSettings: vi.fn(() => liveSettings),
updateSettings: vi.fn(async (updater: any) => {
updater(liveSettings);
}),
}, },
}, },
serviceModules: { serviceModules: {
@@ -56,6 +67,115 @@ async function createSetupURI(passphrase: string): Promise<string> {
return await processSetting.encodeSettingsToSetupURI(settings, passphrase); return await processSetting.encodeSettingsToSetupURI(settings, passphrase);
} }
function captureStdout() {
const writes: string[] = [];
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
writes.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
return {
spy,
lines: () =>
writes
.join("")
.split("\n")
.map((e) => e.trim())
.filter((e) => e.length > 0),
};
}
function parseAddedRemoteIdFromLines(lines: string[]): string {
// remote-add prints: <id>\t<name>\t<redacted-connstr>
const last = lines.length > 0 ? lines[lines.length - 1] : "";
return last.split("\t")[0] || "";
}
type ProtocolFixture = {
protocol: string;
connectionString: string;
assertProjectedFields: (settings: any) => void;
};
const protocolFixtures: ProtocolFixture[] = [
{
protocol: "couchdb",
connectionString: ConnectionStringParser.serialize({
type: "couchdb",
settings: {
couchDB_URI: "https://db.example.com:5984",
couchDB_USER: "user1",
couchDB_PASSWORD: "pass1",
couchDB_DBNAME: "vault1",
couchDB_CustomHeaders: "",
useJWT: false,
jwtAlgorithm: "",
jwtKey: "",
jwtKid: "",
jwtSub: "",
jwtExpDuration: 5,
useRequestAPI: false,
},
}),
assertProjectedFields: (settings) => {
expect(settings.remoteType).toBe(REMOTE_COUCHDB);
expect(settings.couchDB_URI).toBe("https://db.example.com:5984");
expect(settings.couchDB_USER).toBe("user1");
expect(settings.couchDB_PASSWORD).toBe("pass1");
expect(settings.couchDB_DBNAME).toBe("vault1");
},
},
{
protocol: "s3",
connectionString: ConnectionStringParser.serialize({
type: "s3",
settings: {
accessKey: "ak",
secretKey: "sk",
endpoint: "https://s3.example.com",
bucket: "bucket-1",
region: "ap-northeast-1",
bucketPrefix: "vault/",
useCustomRequestHandler: true,
bucketCustomHeaders: "x-test:1",
forcePathStyle: false,
},
}),
assertProjectedFields: (settings) => {
expect(settings.remoteType).toBe(REMOTE_MINIO);
expect(settings.accessKey).toBe("ak");
expect(settings.secretKey).toBe("sk");
expect(settings.endpoint).toBe("https://s3.example.com");
expect(settings.bucket).toBe("bucket-1");
expect(settings.region).toBe("ap-northeast-1");
},
},
{
protocol: "p2p",
connectionString: ConnectionStringParser.serialize({
type: "p2p",
settings: {
P2P_Enabled: false,
P2P_roomID: "room-abc",
P2P_passphrase: "pass-123",
P2P_relays: "wss://relay.example",
P2P_AppID: "self-hosted-livesync",
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
P2P_turnServers: "turn:turn.example:3478",
P2P_turnUsername: "turn-user",
P2P_turnCredential: "turn-pass",
},
}),
assertProjectedFields: (settings) => {
expect(settings.remoteType).toBe(REMOTE_P2P);
expect(settings.P2P_roomID).toBe("room-abc");
expect(settings.P2P_passphrase).toBe("pass-123");
expect(settings.P2P_relays).toBe("wss://relay.example");
expect(settings.P2P_AppID).toBe("self-hosted-livesync");
},
},
];
describe("runCommand abnormal cases", () => { describe("runCommand abnormal cases", () => {
const context = { const context = {
databasePath: "/tmp/vault", databasePath: "/tmp/vault",
@@ -202,4 +322,254 @@ describe("runCommand abnormal cases", () => {
expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled(); expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled();
expect(core.services.control.applySettings).not.toHaveBeenCalled(); expect(core.services.control.applySettings).not.toHaveBeenCalled();
}); });
it("remote-add stores canonical URI and prints the created id", async () => {
const core = createCoreMock();
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const result = await runCommand(makeOptions("remote-add", ["my-remote", "sls+https://example.com/db"]), {
...context,
core,
});
expect(result).toBe(true);
const settings = core.services.setting.currentSettings();
const ids = Object.keys(settings.remoteConfigurations);
expect(ids.length).toBe(1);
expect(settings.remoteConfigurations[ids[0]].name).toBe("my-remote");
expect(settings.remoteConfigurations[ids[0]].uri).toContain("sls+https://example.com/db");
expect(settings.activeConfigurationId).toBe(ids[0]);
expect(stdout).toHaveBeenCalled();
});
it("remote-activate switches active remote and applies settings", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://example.com/db1",
isEncrypted: false,
};
settings.remoteConfigurations.r2 = {
id: "r2",
name: "R2",
uri: "sls+https://example.com/db2",
isEncrypted: false,
};
settings.activeConfigurationId = "r1";
const result = await runCommand(makeOptions("remote-activate", ["r2"]), {
...context,
core,
});
expect(result).toBe(true);
expect(settings.activeConfigurationId).toBe("r2");
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
});
it("remote-rm removes active remote and promotes first remaining", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://example.com/db1",
isEncrypted: false,
};
settings.remoteConfigurations.r2 = {
id: "r2",
name: "R2",
uri: "sls+https://example.com/db2",
isEncrypted: false,
};
settings.activeConfigurationId = "r1";
const result = await runCommand(makeOptions("remote-rm", ["r1"]), {
...context,
core,
});
expect(result).toBe(true);
expect(settings.remoteConfigurations.r1).toBeUndefined();
expect(settings.activeConfigurationId).toBe("r2");
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
});
it("remote-export prints the exact stored connection string", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://example.com/db?db=vault",
isEncrypted: false,
};
const stdout = captureStdout();
const result = await runCommand(makeOptions("remote-export", ["r1"]), {
...context,
core,
});
expect(result).toBe(true);
const outLines = stdout.lines();
expect(outLines.length > 0 ? outLines[outLines.length - 1] : "").toBe("sls+https://example.com/db?db=vault");
expect(stdout.spy).toHaveBeenCalled();
});
it("remote-set updates URI and applies settings when target is active", async () => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://old.example/db",
isEncrypted: false,
};
settings.activeConfigurationId = "r1";
const result = await runCommand(makeOptions("remote-set", ["r1", "sls+https://new.example/db"]), {
...context,
core,
});
expect(result).toBe(true);
expect(settings.remoteConfigurations.r1.uri).toContain("sls+https://new.example/db");
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
});
it.each(protocolFixtures)(
"remote-activate projects effective settings for $protocol",
async ({ connectionString, assertProjectedFields }) => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://old.example/?db=old",
isEncrypted: false,
};
settings.remoteConfigurations.r2 = {
id: "r2",
name: "R2",
uri: connectionString,
isEncrypted: false,
};
settings.activeConfigurationId = "r1";
const result = await runCommand(makeOptions("remote-activate", ["r2"]), {
...context,
core,
});
expect(result).toBe(true);
expect(settings.activeConfigurationId).toBe("r2");
assertProjectedFields(settings);
}
);
it.each(protocolFixtures)(
"remote-set projects effective settings for active remote ($protocol)",
async ({ connectionString, assertProjectedFields }) => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://old.example/?db=old",
isEncrypted: false,
};
settings.activeConfigurationId = "r1";
const result = await runCommand(makeOptions("remote-set", ["r1", connectionString]), {
...context,
core,
});
expect(result).toBe(true);
assertProjectedFields(settings);
}
);
it.each(protocolFixtures)(
"remote-rm projects promoted active remote effective settings for $protocol",
async ({ connectionString, assertProjectedFields }) => {
const core = createCoreMock();
const settings = core.services.setting.currentSettings();
settings.remoteConfigurations.r1 = {
id: "r1",
name: "R1",
uri: "sls+https://old.example/?db=old",
isEncrypted: false,
};
settings.remoteConfigurations.r2 = {
id: "r2",
name: "R2",
uri: connectionString,
isEncrypted: false,
};
settings.activeConfigurationId = "r1";
const result = await runCommand(makeOptions("remote-rm", ["r1"]), {
...context,
core,
});
expect(result).toBe(true);
expect(settings.activeConfigurationId).toBe("r2");
assertProjectedFields(settings);
}
);
it.each([
["couchdb", "sls+https://user:pass@example.com:5984/?db=vault"] as const,
[
"s3",
"sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=my-bucket&region=ap-northeast-1",
] as const,
[
"p2p",
"sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync",
] as const,
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
const core = createCoreMock();
const addOut = captureStdout();
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
...context,
core,
});
expect(addResult).toBe(true);
const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
expect(remoteId).not.toBe("");
const export1Out = captureStdout();
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
});
expect(export1Result).toBe(true);
const export1Lines = export1Out.lines();
const exported1 = export1Lines.length > 0 ? export1Lines[export1Lines.length - 1] : "";
expect(exported1).toBe(ConnectionStringParser.serialize(ConnectionStringParser.parse(initialConnStr)));
const roundTripInput = ConnectionStringParser.serialize(ConnectionStringParser.parse(exported1));
const setResult = await runCommand(makeOptions("remote-set", [remoteId, roundTripInput]), {
...context,
core,
});
expect(setResult).toBe(true);
const export2Out = captureStdout();
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context,
core,
});
expect(export2Result).toBe(true);
const export2Lines = export2Out.lines();
const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : "";
expect(exported2).toBe(roundTripInput);
});
}); });

View File

@@ -20,6 +20,12 @@ export type CLICommand =
| "rm" | "rm"
| "resolve" | "resolve"
| "mirror" | "mirror"
| "remote-add"
| "remote-rm"
| "remote-ls"
| "remote-export"
| "remote-set"
| "remote-activate"
| "init-settings"; | "init-settings";
export interface CLIOptions { export interface CLIOptions {
@@ -37,7 +43,16 @@ export interface CLICommandContext {
databasePath: string; databasePath: string;
core: LiveSyncBaseCore<ServiceContext, any>; core: LiveSyncBaseCore<ServiceContext, any>;
settingsPath: string; settingsPath: string;
originalSyncSettings: Pick<ObsidianLiveSyncSettings, "liveSync" | "syncOnStart" | "periodicReplication" | "syncOnSave" | "syncOnEditorSave" | "syncOnFileOpen" | "syncAfterMerge">; originalSyncSettings: Pick<
ObsidianLiveSyncSettings,
| "liveSync"
| "syncOnStart"
| "periodicReplication"
| "syncOnSave"
| "syncOnEditorSave"
| "syncOnFileOpen"
| "syncAfterMerge"
>;
} }
export const VALID_COMMANDS = new Set([ export const VALID_COMMANDS = new Set([
@@ -58,5 +73,11 @@ export const VALID_COMMANDS = new Set([
"rm", "rm",
"resolve", "resolve",
"mirror", "mirror",
"remote-add",
"remote-rm",
"remote-ls",
"remote-export",
"remote-set",
"remote-activate",
"init-settings", "init-settings",
] as const); ] as const);

View File

@@ -8,7 +8,6 @@ import * as path from "path";
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage"; import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore"; import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P";
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types"; import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types";
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub";
@@ -27,6 +26,7 @@ import type { CLICommand, CLIOptions } from "./commands/types";
import { getPathFromUXFileInfo } from "@lib/common/typeUtils"; import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
import { stripAllPrefixes } from "@lib/string_and_binary/path"; import { stripAllPrefixes } from "@lib/string_and_binary/path";
import { IgnoreRules } from "./serviceModules/IgnoreRules"; import { IgnoreRules } from "./serviceModules/IgnoreRules";
import { useP2PReplicatorFeature } from "@/lib/src/replication/trystero/useP2PReplicatorFeature";
const SETTINGS_FILE = ".livesync/settings.json"; const SETTINGS_FILE = ".livesync/settings.json";
ensureGlobalNodeLocalStorage(); ensureGlobalNodeLocalStorage();
@@ -62,6 +62,16 @@ Commands:
rm <path> Mark a file as deleted in local database rm <path> Mark a file as deleted in local database
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path) mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
remote-add <name> <connstr>
Add a remote configuration from a connection string
remote-rm <remote-id> Remove a remote configuration by ID
remote-ls List stored remote configurations
remote-export <remote-id>
Export a remote connection string by ID
remote-set <remote-id> <connstr>
Replace a stored remote connection string by ID
remote-activate <remote-id>
Activate a stored remote configuration by ID
Options: Options:
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed --interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
@@ -84,6 +94,12 @@ Examples:
livesync-cli ./my-database info notes/hello.md livesync-cli ./my-database info notes/hello.md
livesync-cli ./my-database rm notes/hello.md livesync-cli ./my-database rm notes/hello.md
livesync-cli ./my-database resolve notes/hello.md 3-abcdef livesync-cli ./my-database resolve notes/hello.md 3-abcdef
livesync-cli ./my-database remote-add my-remote "sls+https://user:pass@example.com/db"
livesync-cli ./my-database remote-ls
livesync-cli ./my-database remote-export remote-abc123
livesync-cli ./my-database remote-set remote-abc123 "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=mybucket"
livesync-cli ./my-database remote-activate remote-abc123
livesync-cli ./my-database remote-rm remote-abc123
livesync-cli init-settings ./data.json livesync-cli init-settings ./data.json
livesync-cli ./my-database --verbose livesync-cli ./my-database --verbose
`); `);
@@ -229,6 +245,9 @@ export async function main() {
options.command === "cat" || options.command === "cat" ||
options.command === "cat-rev" || options.command === "cat-rev" ||
options.command === "ls" || options.command === "ls" ||
options.command === "remote-add" ||
options.command === "remote-ls" ||
options.command === "remote-export" ||
options.command === "p2p-peers" || options.command === "p2p-peers" ||
options.command === "info" || options.command === "info" ||
options.command === "rm" || options.command === "rm" ||
@@ -280,16 +299,13 @@ export async function main() {
// chokidar's ignored option is populated when beginWatch() fires during onLoad(). // chokidar's ignored option is populated when beginWatch() fires during onLoad().
const watchEnabled = options.command === "daemon"; const watchEnabled = options.command === "daemon";
const vaultPath = const vaultPath =
options.command === "mirror" && options.commandArgs[0] options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath;
? path.resolve(options.commandArgs[0])
: databasePath;
let ignoreRules: IgnoreRules | undefined; let ignoreRules: IgnoreRules | undefined;
if (options.command === "daemon" || options.command === "mirror") { if (options.command === "daemon" || options.command === "mirror") {
ignoreRules = new IgnoreRules(vaultPath); ignoreRules = new IgnoreRules(vaultPath);
await ignoreRules.load(); await ignoreRules.load();
} }
// Create service context and hub // Create service context and hub
const context = new NodeServiceContext(databasePath); const context = new NodeServiceContext(databasePath);
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context); const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
@@ -371,12 +387,11 @@ export async function main() {
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => { (core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled); return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
}, },
(core) => [ (core) => [],
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
// new ModuleReplicatorP2P(core),
],
() => [], // No add-ons () => [], // No add-ons
(core) => { (core) => {
// Register P2P replicator feature.
const _replicator = useP2PReplicatorFeature(core);
// Add target filter to prevent internal files are handled // Add target filter to prevent internal files are handled
core.services.vault.isTargetFile.addHandler(async (target) => { core.services.vault.isTargetFile.addHandler(async (target) => {
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target)); const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
@@ -427,7 +442,7 @@ export async function main() {
// Save the settings file before any lifecycle events can mutate and persist them. // Save the settings file before any lifecycle events can mutate and persist them.
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and // suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
// various code paths persist the clobbered state to disk. We restore on shutdown. // various code paths persist the clobbered state to disk. We restore on shutdown.
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null); const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
// Restore settings file on any exit to undo lifecycle mutations. // Restore settings file on any exit to undo lifecycle mutations.
// Write to a temp path first so a crash mid-write doesn't leave a truncated file. // Write to a temp path first so a crash mid-write doesn't leave a truncated file.

View File

@@ -86,6 +86,56 @@ describe("CLI parseArgs", () => {
expect(parsed.commandArgs).toEqual([]); expect(parsed.commandArgs).toEqual([]);
}); });
it("parses remote-add command", () => {
process.argv = [
"node",
"livesync-cli",
"./databasePath",
"remote-add",
"my-remote",
"sls+https://user:pass@example.com/db",
];
const parsed = parseArgs();
expect(parsed.databasePath).toBe("./databasePath");
expect(parsed.command).toBe("remote-add");
expect(parsed.commandArgs).toEqual(["my-remote", "sls+https://user:pass@example.com/db"]);
});
it("parses remote-activate command", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "remote-activate", "remote-abc"];
const parsed = parseArgs();
expect(parsed.databasePath).toBe("./databasePath");
expect(parsed.command).toBe("remote-activate");
expect(parsed.commandArgs).toEqual(["remote-abc"]);
});
it("parses remote-export command", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "remote-export", "remote-abc"];
const parsed = parseArgs();
expect(parsed.databasePath).toBe("./databasePath");
expect(parsed.command).toBe("remote-export");
expect(parsed.commandArgs).toEqual(["remote-abc"]);
});
it("parses remote-set command", () => {
process.argv = [
"node",
"livesync-cli",
"./databasePath",
"remote-set",
"remote-abc",
"sls+p2p://room-1?passphrase=abc",
];
const parsed = parseArgs();
expect(parsed.databasePath).toBe("./databasePath");
expect(parsed.command).toBe("remote-set");
expect(parsed.commandArgs).toEqual(["remote-abc", "sls+p2p://room-1?passphrase=abc"]);
});
it("parses --interval flag with valid integer", () => { it("parses --interval flag with valid integer", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"]; process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"];
const parsed = parseArgs(); const parsed = parseArgs();

View File

@@ -97,7 +97,11 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
class CLIWatchAdapter implements IStorageEventWatchAdapter { class CLIWatchAdapter implements IStorageEventWatchAdapter {
private _watcher: FSWatcher | undefined; private _watcher: FSWatcher | undefined;
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {} constructor(
private basePath: string,
private ignoreRules?: IgnoreRules,
private watchEnabled: boolean = false
) {}
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile { private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
return { return {

View File

@@ -60,10 +60,7 @@ describe("CLIStorageEventManagerAdapter", () => {
await adapter.watch.beginWatch(handlers); await adapter.watch.beginWatch(handlers);
expect(chokidar.watch).toHaveBeenCalledTimes(1); expect(chokidar.watch).toHaveBeenCalledTimes(1);
expect(chokidar.watch).toHaveBeenCalledWith( expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
"/base",
expect.objectContaining({ ignoreInitial: true })
);
}); });
it("add event produces NodeFile with correct relative path via onCreate", async () => { it("add event produces NodeFile with correct relative path via onCreate", async () => {

View File

@@ -25,7 +25,7 @@ export function initialiseServiceModulesCLI(
core: LiveSyncBaseCore<ServiceContext, any>, core: LiveSyncBaseCore<ServiceContext, any>,
services: InjectableServiceHub<ServiceContext>, services: InjectableServiceHub<ServiceContext>,
ignoreRules?: IgnoreRules, ignoreRules?: IgnoreRules,
watchEnabled: boolean = false, watchEnabled: boolean = false
): ServiceModules { ): ServiceModules {
const storageAccessManager = new StorageAccessManager(); const storageAccessManager = new StorageAccessManager();
@@ -39,13 +39,19 @@ export function initialiseServiceModulesCLI(
}); });
// CLI-specific storage event manager // CLI-specific storage event manager
const storageEventManager = new StorageEventManagerCLI(basePath, core, { const storageEventManager = new StorageEventManagerCLI(
basePath,
core,
{
fileProcessing: services.fileProcessing, fileProcessing: services.fileProcessing,
setting: services.setting, setting: services.setting,
vaultService: services.vault, vaultService: services.vault,
storageAccessManager: storageAccessManager, storageAccessManager: storageAccessManager,
APIService: services.API, APIService: services.API,
}, ignoreRules, watchEnabled); },
ignoreRules,
watchEnabled
);
// Close the file watcher during graceful shutdown so the process can exit cleanly. // Close the file watcher during graceful shutdown so the process can exit cleanly.
services.appLifecycle.onUnload.addHandler(async () => { services.appLifecycle.onUnload.addHandler(async () => {

View File

@@ -55,7 +55,9 @@ export class IgnoreRules {
continue; continue;
} }
if (trimmed.startsWith("import:")) { if (trimmed.startsWith("import:")) {
console.error(`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`); console.error(
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
);
continue; continue;
} }
this._addPattern(trimmed); this._addPattern(trimmed);

View File

@@ -122,10 +122,7 @@ describe("IgnoreRules", () => {
describe("load() with comments and blank lines", () => { describe("load() with comments and blank lines", () => {
it("skips # comment lines and blank lines", async () => { it("skips # comment lines and blank lines", async () => {
const vaultPath = await createVault(); const vaultPath = await createVault();
await writeIgnoreFile( await writeIgnoreFile(vaultPath, "# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n");
vaultPath,
"# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n"
);
const rules = new IgnoreRules(vaultPath); const rules = new IgnoreRules(vaultPath);
await rules.load(); await rules.load();
expect(rules.shouldIgnore("scratch.tmp")).toBe(true); expect(rules.shouldIgnore("scratch.tmp")).toBe(true);

View File

@@ -29,7 +29,8 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : ""; const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : ""; const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : ""; const minioEndpoint =
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : ""; const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : ""; const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : ""; const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";

View File

@@ -47,7 +47,8 @@ function injectBanner(): import("vite").Plugin {
// Insert after the shebang line if present, otherwise at the top. // Insert after the shebang line if present, otherwise at the top.
if (chunk.code.startsWith("#!")) { if (chunk.code.startsWith("#!")) {
const newline = chunk.code.indexOf("\n"); const newline = chunk.code.indexOf("\n");
chunk.code = chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1); chunk.code =
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
} else { } else {
chunk.code = fileReaderPolyfillBanner + chunk.code; chunk.code = fileReaderPolyfillBanner + chunk.code;
} }

View File

@@ -41,7 +41,7 @@ async function renderHistoryList(): Promise<VaultHistoryItem[]> {
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
listEl.innerHTML = ""; listEl.replaceChildren();
emptyEl.classList.toggle("is-hidden", items.length > 0); emptyEl.classList.toggle("is-hidden", items.length > 0);
for (const item of items) { for (const item of items) {

142
src/common/reportTool.ts Normal file
View File

@@ -0,0 +1,142 @@
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
import { generateCredentialObject } from "@lib/replication/httplib";
import { parseHeaderValues } from "@lib/common/utils";
import { requestToCouchDBWithCredentials } from "./utils";
import { LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger";
import { DEFAULT_SETTINGS } from "@lib/common/models/setting.const.defaults";
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
import { compatGlobal } from "@lib/common/coreEnvFunctions";
import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
function redactObject(obj: Record<string, any>, dotted: string, redactedValue = "REDACTED") {
const keys = dotted.split(".");
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current)) {
current[key] = {} as Record<string, any>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
current = current[key];
}
const lastKey = keys[keys.length - 1];
if (lastKey in current) {
current[lastKey] = redactedValue;
}
return obj;
}
export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) {
let responseConfig: Record<string, any> = {};
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
if (settings.remoteType == REMOTE_COUCHDB) {
try {
const credential = generateCredentialObject(settings);
const customHeaders = parseHeaderValues(settings.couchDB_CustomHeaders);
const r = await requestToCouchDBWithCredentials(
settings.couchDB_URI,
credential,
window.origin,
undefined,
undefined,
undefined,
customHeaders
);
responseConfig = r.json as Record<string, any>;
redactObject(responseConfig, "couch_httpd_auth.secret");
redactObject(responseConfig, "couch_httpd_auth.authentication_db");
redactObject(responseConfig, "couch_httpd_auth.authentication_redirect");
redactObject(responseConfig, "couchdb.uuid");
redactObject(responseConfig, "admins");
redactObject(responseConfig, "users");
redactObject(responseConfig, "chttpd_auth.secret");
delete responseConfig["jwt_keys"];
} catch (ex) {
Logger(ex, LOG_LEVEL_VERBOSE);
responseConfig = {
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
};
}
} else if (settings.remoteType == REMOTE_MINIO) {
responseConfig = { error: "Object Storage Synchronisation" };
//
}
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
const pluginConfig = JSON.parse(JSON.stringify(settings)) as ObsidianLiveSyncSettings;
const pluginKeys = Object.keys(pluginConfig);
for (const key of pluginKeys) {
if (defaultKeys.includes(key as keyof ObsidianLiveSyncSettings)) continue;
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
}
pluginConfig.couchDB_DBNAME = REDACTED;
pluginConfig.couchDB_PASSWORD = REDACTED;
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
? "(HTTP)"
: pluginConfig.couchDB_URI.startsWith("https:")
? "(HTTPS)"
: "";
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`;
pluginConfig.couchDB_USER = REDACTED;
pluginConfig.passphrase = REDACTED;
pluginConfig.encryptedPassphrase = REDACTED;
pluginConfig.encryptedCouchDBConnection = REDACTED;
pluginConfig.accessKey = REDACTED;
pluginConfig.secretKey = REDACTED;
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
const toSchemeOnly = (uri: string) => {
try {
return `${new URL(uri).protocol}//`;
} catch {
const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//);
return matched?.[0] ?? REDACTED;
}
};
pluginConfig.remoteConfigurations = Object.fromEntries(
Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [
id,
{
...config,
uri: toSchemeOnly(config.uri),
},
])
);
pluginConfig.region = redact(pluginConfig.region);
pluginConfig.bucket = redact(pluginConfig.bucket);
pluginConfig.pluginSyncExtendedSetting = {};
pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID);
pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase);
pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID);
pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays);
pluginConfig.jwtKey = redact(pluginConfig.jwtKey);
pluginConfig.jwtSub = redact(pluginConfig.jwtSub);
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";
} else {
const endpointScheme = pluginConfig.endpoint.startsWith("http:")
? "(HTTP)"
: pluginConfig.endpoint.startsWith("https:")
? "(HTTPS)"
: "";
pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`;
}
const obsidianInfo = {
navigator: compatGlobal.navigator.userAgent,
fileSystem: core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive",
};
const result = {
obsidianInfo,
responseConfig,
pluginConfig,
manifestVersion,
packageVersion,
};
return result;
}

View File

@@ -0,0 +1,80 @@
import { App, Modal } from "@/deps.ts";
import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
import { mount, unmount } from "svelte";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
export type P2POpenReplicationModalCallback = {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
};
export class P2POpenReplicationModal extends Modal {
liveSyncReplicator: LiveSyncTrysteroReplicator;
callback?: P2POpenReplicationModalCallback;
component?: ReturnType<typeof mount>;
showResult: boolean;
title: string;
onClosed?: () => void;
rebuildMode: boolean;
constructor(
app: App,
liveSyncReplicator: LiveSyncTrysteroReplicator,
callback?: P2POpenReplicationModalCallback,
showResult: boolean = false,
title: string = "P2P Replication",
onClosed?: () => void,
rebuildMode: boolean = false
) {
super(app);
this.liveSyncReplicator = liveSyncReplicator;
this.callback = callback;
this.showResult = showResult;
this.title = title;
this.onClosed = onClosed;
this.rebuildMode = rebuildMode;
}
async onSync(peerId: string) {
if (this.callback?.onSync) {
await this.callback.onSync(peerId);
}
}
async onSyncAndClose(peerId: string) {
if (this.callback?.onSyncAndClose) {
await this.callback.onSyncAndClose(peerId);
}
this.close();
}
override onOpen() {
const { contentEl } = this;
this.titleEl.setText(this.title);
contentEl.empty();
if (this.component === undefined) {
this.component = mount(P2POpenReplicationPane, {
target: contentEl,
props: {
liveSyncReplicator: this.liveSyncReplicator,
onSync: (peerId: string) => this.onSync(peerId),
onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId),
onClose: () => this.close(),
showResult: this.showResult,
rebuildMode: this.rebuildMode,
},
});
}
}
override onClose() {
const { contentEl } = this;
contentEl.empty();
if (this.component !== undefined) {
void unmount(this.component);
this.component = undefined;
}
this.onClosed?.();
}
}

View File

@@ -0,0 +1,313 @@
<script lang="ts">
import { onMount } from "svelte";
import { eventHub } from "@/common/events";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
type P2PServerInfo,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
// import type { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
import { Logger } from "@lib/common/logger";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
onSync: (_peerId: string) => Promise<void>;
onSyncAndClose: (_peerId: string) => Promise<void>;
onClose: () => void;
showResult: boolean;
rebuildMode?: boolean;
}
let { onSync, onSyncAndClose, onClose, showResult, liveSyncReplicator, rebuildMode = false }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let syncingPeerId = $state<string | null>(null);
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
async function requestServerStatus() {
await liveSyncReplicator.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
onMount(() => {
// ServerStatus
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
});
fireAndForget(async () => {
await delay(100);
await requestServerStatus();
});
return unsubscribe;
});
async function handleSync(peerId: string) {
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
syncingPeerId = null;
}
}
async function handleSyncThenClose(peerId: string) {
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSyncAndClose(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
syncingPeerId = null;
}
}
async function handleSyncAndClose(peerId: string) {
fireAndForget(async () => {
try {
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
}
});
onClose();
}
async function disconnect() {
try {
await liveSyncReplicator.close();
Logger("Signalling connection closed.", logLevel);
} catch (e) {
Logger(`Failed to close signalling connection: ${e instanceof Error ? e.message : String(e)}`, logLevel);
}
}
async function onCloseAndDisconnect() {
await disconnect();
onClose();
}
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
if (peer.isAccepted === true) return "ACCEPTED";
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
if (peer.isAccepted === false) return "DENIED";
return "NEW";
}
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
return "unknown";
}
</script>
<div class="p2p-container">
<P2PServerStatusCard {liveSyncReplicator} showBroadcastToggle={false} />
<div class="peers-section">
<h3>Available Peers</h3>
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
<div class="peers-list">
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
<div class="peer-item">
<div class="peer-info">
<div class="peer-name">{peer.name}</div>
<div class="peer-meta">
<span class="badge">{peer.platform}</span>
<span class="peer-id-mini" title={peer.peerId}>
{peer.peerId.slice(0, 8)}
</span>
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
</div>
</div>
<div class="peer-actions">
{#if !rebuildMode}
<button
class="btn btn-primary"
disabled={syncingPeerId !== null}
onclick={() => handleSync(peer.peerId)}
>
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
</button>
<button
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
disabled={syncingPeerId !== null}
onclick={() => handleSyncAndClose(peer.peerId)}
>
{syncingPeerId === peer.peerId ? "Syncing..." : "Start Sync & Close"}
</button>
{:else}
<button
class="btn {rebuildMode ? 'btn-primary' : 'btn-secondary'}"
disabled={syncingPeerId !== null}
onclick={() => handleSyncThenClose(peer.peerId)}
>
{syncingPeerId === peer.peerId ? "Syncing..." : "Sync"}
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else if serverInfo}
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
{/if}
</div>
<div class="footer">
{#if rebuildMode}
<button class="btn btn-cancel" onclick={onClose} disabled={syncingPeerId !== null}>Skip and close</button>
{:else}
<button class="btn btn-cancel" onclick={onClose}>Close</button>
<button class="btn btn-cancel" onclick={onCloseAndDisconnect}>Close & Disconnect</button>
{/if}
</div>
</div>
<style>
.p2p-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-height: 70vh;
overflow-y: auto;
}
.peers-section {
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-weight: 600;
font-size: 1rem;
}
.peers-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.peer-item {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
padding: 0.75rem;
background-color: var(--background-secondary);
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
}
.peer-info {
flex: 1;
}
.peer-name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.peer-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
}
.badge {
background-color: var(--background-tertiary);
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.status-chip {
font-weight: 600;
}
.status-chip.accepted {
background-color: var(--background-modifier-success);
color: var(--text-normal);
}
.status-chip.denied {
background-color: var(--background-modifier-error);
color: var(--text-normal);
}
.status-chip.unknown {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.peer-id-mini {
font-family: monospace;
color: var(--text-muted);
}
.peer-actions {
flex-wrap: wrap;
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}
.btn-secondary {
background-color: var(--background-tertiary);
}
.btn-cancel {
width: 100%;
margin-top: 0.5rem;
}
.no-peers {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem;
}
.footer {
border-top: 1px solid var(--divider-color);
padding-top: 0.75rem;
}
</style>

View File

@@ -0,0 +1,131 @@
import { App } from "@/deps.ts";
import { Logger } from "@lib/common/logger";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types";
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
/**
* Creates an openReplicationUI factory for Obsidian environments.
* Returns a per-replicator closure that opens the P2P Replication modal
* and performs bidirectional sync (pull then push on success).
*
* Usage:
* const factory = createOpenReplicationUI(app);
* useP2PReplicatorFeature(core, factory);
*/
export function createOpenReplicationUI(
app: App
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
const modal = new P2POpenReplicationModal(
app,
replicator,
{
onSync: async (peerId: string) => {
try {
// pull (replicateFrom) first; push only on success
const pullResult = await replicator.replicateFrom(peerId, showResult);
if (pullResult?.ok) {
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
resolve(pushResult?.ok ?? true);
} else {
resolve(false);
}
} catch (e) {
Logger(
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
resolve(false);
}
},
onSyncAndClose: async (peerId: string) => {
try {
const pullResult = await replicator.replicateFrom(peerId, showResult);
if (pullResult?.ok) {
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
if (pushResult?.ok ?? true) {
await replicator.close();
resolve(true);
} else {
resolve(false);
}
} else {
resolve(false);
}
} catch (e) {
Logger(
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
resolve(false);
}
},
},
showResult
);
modal.open();
});
};
}
/**
* Creates an openRebuildUI factory for Obsidian environments.
* Opens the P2P Replication modal in "rebuild" mode — one-way pull only,
* with setOnSetup / clearOnSetup bracketing the replicateFrom call.
*
* Usage:
* const factory = createOpenRebuildUI(app);
* useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory);
*/
export function createOpenRebuildUI(
app: App
): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
let resolved = false;
const safeResolve = (val: boolean) => {
if (!resolved) {
resolved = true;
resolve(val);
}
};
const doRebuild = async (peerId: string) => {
replicator.setOnSetup();
try {
Logger(`Rebuilding from peer ${peerId}`, logLevel);
const result = await replicator.replicateFrom(peerId, showResult);
safeResolve(result?.ok ?? false);
} catch (e) {
Logger(
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
safeResolve(false);
} finally {
replicator.clearOnSetup();
}
};
const modal = new P2POpenReplicationModal(
app,
replicator,
{
onSync: doRebuild,
onSyncAndClose: doRebuild,
},
showResult,
"P2P Rebuild",
() => safeResolve(false),
true
);
modal.open();
});
};
}

View File

@@ -5,20 +5,21 @@
AcceptedStatus, AcceptedStatus,
ConnectionStatus, ConnectionStatus,
type PeerStatus, type PeerStatus,
} from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon"; } from "@lib/replication/trystero/P2PReplicatorPaneCommon";
import type { LiveSyncTrysteroReplicator } from "../../../lib/src/replication/trystero/LiveSyncTrysteroReplicator"; import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte"; import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte";
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events"; import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
import { import {
type PeerInfo, type PeerInfo,
type P2PServerInfo, type P2PServerInfo,
EVENT_SERVER_STATUS, EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS, EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS, EVENT_P2P_REPLICATOR_STATUS,
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer"; } from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator"; import { type P2PReplicatorStatus } from "@lib/replication/trystero/TrysteroReplicator";
import { $msg as _msg } from "../../../lib/src/common/i18n"; import { $msg as _msg } from "@lib/common/i18n";
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types"; import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
import { generateP2PRoomId } from "@lib/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
interface Props { interface Props {
@@ -148,6 +149,7 @@
eventHub.emitEvent(EVENT_REQUEST_STATUS); eventHub.emitEvent(EVENT_REQUEST_STATUS);
return () => { return () => {
r(); r();
rx();
r2(); r2();
r3(); r3();
}; };
@@ -216,18 +218,8 @@
function useDefaultRelay() { function useDefaultRelay() {
eRelay = DEFAULT_SETTINGS.P2P_relays; eRelay = DEFAULT_SETTINGS.P2P_relays;
} }
function _generateRandom() {
return (Math.floor(Math.random() * 1000) + 1000).toString().substring(1);
}
function generateRandom(length: number) {
let buf = "";
while (buf.length < length) {
buf += "-" + _generateRandom();
}
return buf.substring(1, length);
}
function chooseRandom() { function chooseRandom() {
eRoomId = generateRandom(12) + "-" + Math.random().toString(36).substring(2, 5); eRoomId = generateP2PRoomId();
} }
async function openServer() { async function openServer() {
@@ -251,7 +243,7 @@
setting?: boolean; setting?: boolean;
}; };
return initialDialogStatus; return initialDialogStatus;
} catch (e) { } catch {
return {}; return {};
} }
}; };

View File

@@ -0,0 +1,224 @@
<script lang="ts">
import { onMount } from "svelte";
import { eventHub } from "@/common/events";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import type { P2PServerInfo } from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PReplicatorStatus } from "@/lib/src/replication/trystero/TrysteroReplicator";
import { extractP2PRoomSuffix } from "@/lib/src/common/utils";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
showBroadcastToggle?: boolean;
core?: LiveSyncBaseCore;
}
let { liveSyncReplicator, showBroadcastToggle = true, core }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
let roomSuffix = $state<string>(extractP2PRoomSuffix(core?.services.setting.currentSettings()?.P2P_roomID ?? ""));
async function requestServerStatus() {
await Promise.resolve(liveSyncReplicator.requestStatus());
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
async function onOpenConnection() {
await liveSyncReplicator.makeSureOpened();
await requestServerStatus();
}
async function onDisconnect() {
await liveSyncReplicator.close();
await requestServerStatus();
}
function toggleBroadcast() {
if (replicatorStatus?.isBroadcasting) {
liveSyncReplicator.disableBroadcastChanges();
} else {
liveSyncReplicator.enableBroadcastChanges();
}
}
onMount(() => {
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
roomSuffix = extractP2PRoomSuffix(status?.roomId ?? "");
});
const unsubscribeStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorStatus = status;
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
roomSuffix = extractP2PRoomSuffix(settings?.P2P_roomID ?? "");
});
fireAndForget(async () => {
await delay(100);
await requestServerStatus();
});
return () => {
unsubscribe();
unsubscribeStatus();
unsubscribeSettings();
};
});
const isConnected = $derived.by(() => serverInfo?.isConnected);
const isBroadcasting = $derived.by(() => replicatorStatus?.isBroadcasting ?? false);
</script>
<div class="server-status">
<h3>Signalling Status</h3>
<div class="status-item">
<span>Connection:</span>
<span class="status-value {isConnected ? 'connected' : 'disconnected'}">
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
</span>
</div>
<div class="status-item status-action">
{#if !isConnected}
<button onclick={onOpenConnection}>Open connection</button>
{:else}
<button onclick={onDisconnect}>Close connection</button>
{/if}
</div>
{#if serverInfo}
<div class="status-item">
<span>Room ID suffix:</span>
<span class="room-suffix-display" title={roomSuffix || "Not configured"}>
{roomSuffix || "-"}
</span>
</div>
<div class="status-item">
<span>Peer ID:</span>
<span class="peer-id-display" title={serverInfo.serverPeerId}>
{serverInfo.serverPeerId.slice(0, 12)}...
</span>
</div>
<div class="status-item">
<span>Devices:</span>
<span>{serverInfo.knownAdvertisements.length}</span>
</div>
{/if}
{#if showBroadcastToggle}
<div class="status-item status-action broadcast-row">
<!-- Live-push to peers: stream this device's changes to connected peers for LiveSync -->
<label class="broadcast-label" for="broadcast-toggle">
Live-push to peers
</label>
<button
id="broadcast-toggle"
class="broadcast-button {isBroadcasting ? 'is-on' : 'is-off'}"
onclick={toggleBroadcast}
title={isBroadcasting ? 'Pushing changes to peers — click to stop' : 'Start pushing changes to peers'}
>
{isBroadcasting ? '📡 On' : '📡 Off'}
</button>
</div>
{/if}
</div>
<style>
.server-status {
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-weight: 600;
font-size: 1rem;
}
.status-item {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.status-action {
align-items: center;
gap: 0.5rem;
}
.status-value {
font-weight: 500;
}
.status-value.connected {
color: var(--text-success);
}
.status-value.disconnected {
color: var(--text-error);
}
.peer-id-display {
font-family: monospace;
font-size: 0.85rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
}
.room-suffix-display {
font-family: monospace;
font-size: 0.85rem;
font-weight: 600;
}
.broadcast-row {
align-items: center;
margin-top: 0.25rem;
}
.broadcast-label {
font-size: 0.9rem;
color: var(--text-normal);
cursor: pointer;
}
.broadcast-button {
font-size: 0.8rem;
padding: 0.2rem 0.6rem;
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
cursor: pointer;
font-weight: 600;
transition: background-color 0.15s;
}
.broadcast-button.is-on {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.broadcast-button.is-off {
background-color: var(--interactive-normal);
color: var(--text-muted);
}
.broadcast-button.is-off:hover {
background-color: var(--interactive-hover);
color: var(--text-normal);
}
</style>

View File

@@ -0,0 +1,891 @@
<script lang="ts">
import { onMount } from "svelte";
import { EVENT_LAYOUT_READY, EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
import {
EVENT_SERVER_STATUS,
EVENT_REQUEST_STATUS,
EVENT_P2P_REPLICATOR_STATUS,
EVENT_P2P_REPLICATOR_PROGRESS,
type P2PServerInfo,
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
import type { P2PReplicatorStatus, P2PReplicationReport } from "@lib/replication/trystero/TrysteroReplicator";
import { delay, fireAndForget } from "octagonal-wheels/promises";
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { ConnectionStringParser } from "@lib/common/ConnectionString";
import type { P2PSyncSetting, RemoteConfiguration } from "@lib/common/models/setting.type";
import { activateP2PRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
import { extractP2PRoomSuffix } from "@lib/common/utils";
import { SetupManager } from "@/modules/features/SetupManager";
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
interface Props {
liveSyncReplicator: LiveSyncTrysteroReplicator;
core: LiveSyncBaseCore;
}
let { liveSyncReplicator, core }: Props = $props();
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
let decidingPeerId = $state<string | null>(null);
let replicatingPeerId = $state<string | null>(null);
let communicatingUntil = $state<Record<string, number>>({});
const COMMUNICATION_HOLD_MS = 2500;
let syncOnReplicationSetting = $state(core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "");
type P2PRemoteOption = {
id: string;
name: string;
roomSuffix: string;
};
let p2pRemoteOptions = $state<P2PRemoteOption[]>([]);
let selectedP2PRemoteConfigurationId = $state(
core.services.setting.currentSettings()?.P2P_ActiveRemoteConfigurationId ?? ""
);
let selectingP2PRemote = $state(false);
function addToList(item: string, list: string): string {
const items = list
.split(",")
.map((e) => e.trim())
.filter((e) => e);
if (!items.includes(item)) items.push(item);
return items.join(",");
}
function removeFromList(item: string, list: string): string {
return list
.split(",")
.map((e) => e.trim())
.filter((e) => e && e !== item)
.join(",");
}
function markCommunicating(peerId: string) {
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
communicatingUntil = { ...communicatingUntil, [peerId]: expiry };
window.setTimeout(() => {
if ((communicatingUntil[peerId] ?? 0) <= Date.now()) {
const { [peerId]: _removed, ...rest } = communicatingUntil;
communicatingUntil = rest;
}
}, COMMUNICATION_HOLD_MS + 100);
}
function listP2PRemoteOptions(
remoteConfigurations: Record<string, RemoteConfiguration> | undefined
): P2PRemoteOption[] {
return Object.values(remoteConfigurations ?? {})
.map((config) => {
try {
const parsed = ConnectionStringParser.parse(config.uri);
if (parsed.type !== "p2p") {
return undefined;
}
return {
id: config.id,
name: config.name,
roomSuffix: extractP2PRoomSuffix(parsed.settings.P2P_roomID ?? ""),
} as P2PRemoteOption;
} catch {
return undefined;
}
})
.filter((e): e is P2PRemoteOption => !!e);
}
function refreshP2PRemoteOptions() {
const settings = core.services.setting.currentSettings();
const options = listP2PRemoteOptions(settings.remoteConfigurations);
p2pRemoteOptions = options;
const currentSelected = settings.P2P_ActiveRemoteConfigurationId ?? "";
const isCurrentSelectedValid = options.some((option) => option.id === currentSelected);
if (options.length === 0) {
selectedP2PRemoteConfigurationId = "";
return;
}
if (currentSelected.trim() === "" || !isCurrentSelectedValid) {
const fallbackId = options[0].id;
selectedP2PRemoteConfigurationId = fallbackId;
if (currentSelected !== fallbackId) {
fireAndForget(() => applyP2PActiveRemoteSelection(fallbackId));
}
return;
}
selectedP2PRemoteConfigurationId = currentSelected;
}
function canEditP2PSettings() {
const selected = selectedP2PRemoteConfigurationId.trim();
if (selected === "") {
return false;
}
return p2pRemoteOptions.some((e) => e.id === selected);
}
async function requestServerStatus() {
await liveSyncReplicator.requestStatus();
eventHub.emitEvent(EVENT_REQUEST_STATUS);
}
onMount(() => {
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
});
const unsubscribeReplicatorStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
replicatorInfo = status;
for (const peerId of status.replicatingFrom) {
markCommunicating(peerId);
}
for (const peerId of status.replicatingTo) {
markCommunicating(peerId);
}
});
const unsubscribeReplicatorProgress = eventHub.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (report) => {
const rep = report as P2PReplicationReport;
if (("fetching" in rep && rep.fetching?.isActive) || ("sending" in rep && rep.sending?.isActive)) {
markCommunicating(rep.peerId);
}
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
});
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
refreshP2PRemoteOptions();
void requestServerStatus();
});
fireAndForget(async () => {
await delay(100);
refreshP2PRemoteOptions();
await requestServerStatus();
});
return () => {
unsubscribe();
unsubscribeReplicatorStatus();
unsubscribeReplicatorProgress();
unsubscribeSettings();
unsubscribeLayoutReady();
};
});
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
if (peer.isAccepted === true) return "ACCEPTED";
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
if (peer.isAccepted === false) return "DENIED";
return "NEW";
}
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
return "unknown";
}
function openConnectionSettings() {
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
}
async function applyP2PActiveRemoteSelection(id: string) {
selectingP2PRemote = true;
try {
await core.services.setting.updateSettings((settings) => {
settings.P2P_ActiveRemoteConfigurationId = id;
if (id.trim() === "") {
return settings;
}
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
} finally {
selectingP2PRemote = false;
}
}
async function onP2PRemoteSelected(event: Event) {
const target = event.currentTarget as HTMLSelectElement;
const id = target.value;
selectedP2PRemoteConfigurationId = id;
await applyP2PActiveRemoteSelection(id);
}
async function createAndSelectP2PRemote() {
const setupManager = core.getModule(SetupManager);
const dialogManager = setupManager.dialogManager;
const currentSettings = core.services.setting.currentSettings();
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSettings);
if (p2pConf === "cancelled" || typeof p2pConf !== "object" || !p2pConf) {
return;
}
const p2pSettings = p2pConf as Partial<P2PSyncSetting>;
const id = createRemoteConfigurationId();
const roomSuffix = extractP2PRoomSuffix(p2pSettings.P2P_roomID ?? "");
const name = roomSuffix ? `P2P Remote (${roomSuffix})` : "P2P Remote";
await core.services.setting.updateSettings((settings) => {
const merged = {
...settings,
...p2pSettings,
};
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
settings.remoteConfigurations = {
...(settings.remoteConfigurations ?? {}),
[id]: {
id,
name,
uri,
isEncrypted: false,
},
};
settings.P2P_ActiveRemoteConfigurationId = id;
const activated = activateP2PRemoteConfiguration(settings, id);
return activated || settings;
}, true);
const latest = core.services.setting.currentSettings();
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
refreshP2PRemoteOptions();
}
async function updateSelectedP2PRemote(partial: Partial<P2PSyncSetting>) {
const selectedId = core.services.setting.currentSettings().P2P_ActiveRemoteConfigurationId?.trim() ?? "";
if (selectedId === "") {
return;
}
await core.services.setting.updateSettings((settings) => {
const config = settings.remoteConfigurations?.[selectedId];
if (!config) {
return settings;
}
let parsed;
try {
parsed = ConnectionStringParser.parse(config.uri);
} catch {
return settings;
}
if (parsed.type !== "p2p") {
return settings;
}
const mergedP2P = {
...parsed.settings,
...partial,
};
const uri = ConnectionStringParser.serialize({
type: "p2p",
settings: {
...settings,
...mergedP2P,
},
});
settings.remoteConfigurations = {
...(settings.remoteConfigurations ?? {}),
[selectedId]: {
...config,
uri,
isEncrypted: false,
},
};
Object.assign(settings, partial);
const activated = activateP2PRemoteConfiguration(settings, selectedId);
return activated || settings;
}, true);
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
}
async function makeDecision(
peer: P2PServerInfo["knownAdvertisements"][number],
decision: boolean,
isTemporary: boolean
) {
decidingPeerId = peer.peerId;
try {
await liveSyncReplicator.makeDecision({
peerId: peer.peerId,
name: peer.name,
decision,
isTemporary,
});
await requestServerStatus();
} finally {
decidingPeerId = null;
}
}
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
decidingPeerId = peer.peerId;
try {
await liveSyncReplicator.revokeDecision({
peerId: peer.peerId,
name: peer.name,
});
await requestServerStatus();
} finally {
decidingPeerId = null;
}
}
async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) {
replicatingPeerId = peer.peerId;
try {
const pullResult = await liveSyncReplicator.replicateFrom(peer.peerId, true);
if (pullResult?.ok) {
await liveSyncReplicator.requestSynchroniseToPeer(peer.peerId);
}
await requestServerStatus();
} finally {
replicatingPeerId = null;
}
}
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
}
function isWatching(peerId: string) {
return replicatorInfo?.watchingPeers?.includes(peerId) ?? false;
}
function toggleWatch(peerId: string) {
if (!canEditP2PSettings()) {
return;
}
if (isWatching(peerId)) {
liveSyncReplicator.unwatchPeer(peerId);
} else {
liveSyncReplicator.watchPeer(peerId);
}
}
function isCommunicating(peerId: string) {
const to = replicatorInfo?.replicatingTo ?? [];
const from = replicatorInfo?.replicatingFrom ?? [];
const isLiveCommunicating = to.includes(peerId) || from.includes(peerId);
const isHeldCommunicating = (communicatingUntil[peerId] ?? 0) > Date.now();
return isLiveCommunicating || isHeldCommunicating;
}
function isSyncTarget(peerName: string) {
return syncOnReplicationSetting
.split(",")
.map((e) => e.trim())
.filter((e) => e)
.includes(peerName);
}
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
if (!canEditP2PSettings()) {
return;
}
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
const newValue = isSyncTarget(peer.name)
? removeFromList(peer.name, currentValue)
: addToList(peer.name, currentValue);
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
}
</script>
<div class="p2p-container">
<div class="pane-header">
<h2>P2P Status</h2>
<div class="pane-header-actions">
<div class="remote-picker-wrap">
<select
class="remote-picker"
value={selectedP2PRemoteConfigurationId}
onchange={onP2PRemoteSelected}
disabled={selectingP2PRemote}
aria-label="Select active P2P remote"
title="Select active P2P remote"
>
{#if p2pRemoteOptions.length === 0}
<option value="">Select P2P remote...</option>
{/if}
{#each p2pRemoteOptions as option}
<option value={option.id}>
{option.name}{option.roomSuffix ? ` (${option.roomSuffix})` : ""}
</option>
{/each}
</select>
<button
class="icon-button"
onclick={() => createAndSelectP2PRemote()}
title="Create P2P remote"
aria-label="Create P2P remote"
>
+
</button>
</div>
<button
class="icon-button"
onclick={openConnectionSettings}
title="Open P2P Setup..."
aria-label="Open P2P Setup..."
>
</button>
</div>
</div>
{#if !canEditP2PSettings()}
<p class="warning-line">Please select an active P2P remote configuration to change P2P sync targets.</p>
{/if}
<P2PServerStatusCard {liveSyncReplicator} {core} />
<div class="peers-section">
<div class="peers-header">
<h3>Detected Peers</h3>
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
</div>
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
<div class="peers-list">
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
<div class="peer-item">
<div class="peer-info">
<div class="peer-name">
{peer.name} :
<span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
{#if isCommunicating(peer.peerId)}
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
{/if}
</div>
<div class="peer-meta">
<span class="badge">{peer.platform}</span>
</div>
</div>
<div class="peer-actions">
{#if isAccepted(peer)}
<div class="decision-row accepted-row">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
<button
class="emoji-button"
disabled={replicatingPeerId !== null}
title={replicatingPeerId === peer.peerId ? "Replicating..." : "Replicate now"}
aria-label={replicatingPeerId === peer.peerId ? "Replicating" : "Replicate now"}
onclick={() => startReplication(peer)}
>
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
</button>
<button
class="action-button"
disabled={decidingPeerId !== null}
onclick={() => revokeDecision(peer)}
>
Revoke
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">WATCH</span>
<button
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
title={isWatching(peer.peerId)
? "Watching this peer \u2014 click to stop"
: "Watch this peer's changes"}
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"}
disabled={!canEditP2PSettings()}
onclick={() => toggleWatch(peer.peerId)}
>
{isWatching(peer.peerId) ? "🔔" : "🔕"}
</button>
</div>
<div class="decision-row watch-row">
<span class="decision-label">SYNC</span>
<button
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
title={isSyncTarget(peer.name)
? "Sync target \u2014 click to remove"
: "Set as sync target"}
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
disabled={!canEditP2PSettings()}
onclick={() => toggleSyncTarget(peer)}
>
{isSyncTarget(peer.name) ? "🔗" : "⛓️‍💥"}
</button>
</div>
{:else}
<div class="decision-status">
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
{getAcceptanceStatus(peer)}
</span>
</div>
<div class="decision-row">
<span class="decision-label">PERMANENT</span>
<button
class="emoji-button"
title="Allow permanently"
aria-label="Allow permanently"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, true, false)}
>
</button>
<button
class="emoji-button mod-warning"
title="Deny permanently"
aria-label="Deny permanently"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, false, false)}
>
🚫
</button>
</div>
<div class="decision-row">
<span class="decision-label">SESSION</span>
<button
class="emoji-button"
title="Allow in session"
aria-label="Allow in session"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, true, true)}
>
</button>
<button
class="emoji-button mod-warning"
title="Deny in session"
aria-label="Deny in session"
disabled={decidingPeerId !== null}
onclick={() => makeDecision(peer, false, true)}
>
🚫
</button>
</div>
{/if}
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
<button
class="action-button revoke-inline"
disabled={decidingPeerId !== null}
onclick={() => revokeDecision(peer)}
>
Revoke
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else if serverInfo}
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
{:else}
<p class="no-peers">Fetching status...</p>
{/if}
</div>
</div>
<style>
.p2p-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.peers-section {
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
padding: 1rem;
}
.pane-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.pane-header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.remote-picker-wrap {
display: inline-flex;
gap: 0.3rem;
align-items: center;
min-width: 0;
}
.remote-picker {
max-width: 10rem;
min-width: 1em;
flex-shrink: 1;
height: 1.9rem;
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
padding: 0 0.45rem;
}
.warning-line {
margin: -0.2rem 0 0;
font-size: 0.82rem;
color: var(--text-warning);
}
.pane-header h2 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
white-space: nowrap;
}
.icon-button {
width: 1.9rem;
height: 1.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
flex-shrink: 0;
}
.icon-button:hover {
background-color: var(--interactive-hover);
}
.peers-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
h3 {
margin: 0;
font-weight: 600;
font-size: 1rem;
}
.refresh {
font-size: 0.8rem;
padding: 0.2rem 0.5rem;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
}
.refresh:hover {
background-color: var(--interactive-hover);
}
.peers-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.peer-item {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--background-secondary);
border: 1px solid var(--divider-color);
border-radius: 0.4rem;
}
.peer-info {
flex: 1;
min-width: 0;
}
.peer-name {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.peer-meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
flex-wrap: wrap;
}
.badge {
background-color: var(--background-tertiary);
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
}
.status-chip {
font-weight: 600;
}
.status-chip.accepted {
background-color: var(--background-modifier-success);
color: var(--text-normal);
}
.status-chip.denied {
background-color: var(--background-modifier-error);
color: var(--text-normal);
}
.status-chip.unknown {
background-color: var(--background-modifier-border);
color: var(--text-muted);
}
.peer-id-mini {
font-family: monospace;
color: var(--text-muted);
font-size: 0.75rem;
}
.comm-icon {
font-size: 0.8rem;
line-height: 1;
animation: pulse-comm 1.2s ease-in-out infinite;
}
@keyframes pulse-comm {
0% {
opacity: 0.55;
transform: scale(0.95);
}
50% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0.55;
transform: scale(0.95);
}
}
.peer-actions {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
min-width: 0;
}
.decision-status {
display: flex;
justify-content: flex-start;
}
.decision-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.35rem;
}
.accepted-row {
grid-template-columns: 1fr auto auto;
}
.decision-label {
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.03em;
}
.action-button {
font-size: 0.75rem;
padding: 0.2rem 0.45rem;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.emoji-button {
width: 2rem;
height: 1.7rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--divider-color);
border-radius: 0.3rem;
background-color: var(--interactive-normal);
cursor: pointer;
padding: 0;
line-height: 1;
}
.emoji-button.mod-warning {
background-color: var(--background-modifier-error);
}
.emoji-button.is-watching {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.emoji-button:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.emoji-button.mod-warning:hover:not(:disabled) {
filter: brightness(0.95);
}
.watch-row {
margin-top: 0.25rem;
}
.action-button:hover:not(:disabled) {
background-color: var(--interactive-hover);
}
.action-button.mod-warning {
background-color: var(--background-modifier-error);
}
.action-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.emoji-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.revoke-inline {
justify-self: start;
}
.no-peers {
text-align: center;
color: var(--text-muted);
font-size: 0.9rem;
padding: 1rem;
}
</style>

View File

@@ -0,0 +1,43 @@
import { WorkspaceLeaf } from "@/deps.ts";
import { mount } from "svelte";
import { SvelteItemView } from "@/common/SvelteItemView.ts";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import type { P2PPaneParams } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
import P2PServerStatusPane from "./P2PServerStatusPane.svelte";
export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status";
export class P2PServerStatusPaneView extends SvelteItemView {
core: LiveSyncBaseCore;
private _p2pResult: P2PPaneParams;
override icon = "waypoints";
override navigation = false;
constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) {
super(leaf);
this.core = core;
this._p2pResult = p2pResult;
}
override getIcon(): string {
return "waypoints";
}
getViewType() {
return VIEW_TYPE_P2P_SERVER_STATUS;
}
getDisplayText() {
return "P2P Status";
}
instantiateComponent(target: HTMLElement) {
return mount(P2PServerStatusPane, {
target,
props: {
liveSyncReplicator: this._p2pResult.replicator,
core: this.core,
},
});
}
}

Submodule src/lib updated: adcfe42522...c5beaa3866

View File

@@ -1,5 +1,6 @@
import { Notice, Plugin, type App, type PluginManifest } from "./deps"; import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
import { setGetLanguage } from "./lib/src/common/coreEnvFunctions.ts";
setGetLanguage(getLanguage);
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts"; import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts"; import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts"; import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
@@ -43,6 +44,7 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/
import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts"; import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts";
import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts"; import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts";
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts"; import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>; export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
export default class ObsidianLiveSyncPlugin extends Plugin { export default class ObsidianLiveSyncPlugin extends Plugin {
core: LiveSyncCore; core: LiveSyncCore;
@@ -175,7 +177,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
const curriedFeature = () => featuresInitialiser(core); const curriedFeature = () => featuresInitialiser(core);
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
const setupManager = core.getModule(SetupManager); const setupManager = core.getModule(SetupManager);
const replicator = useP2PReplicatorFeature(
core,
createOpenReplicationUI(this.app),
createOpenRebuildUI(this.app)
);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator);
useRemoteConfiguration(core); useRemoteConfiguration(core);
useSetupProtocolFeature(core, setupManager); useSetupProtocolFeature(core, setupManager);
@@ -189,9 +197,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
// VIEW_TYPE_P2P, // VIEW_TYPE_P2P,
// (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!), // (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!),
// ]); // ]);
const replicator = useP2PReplicatorFeature(core);
useP2PReplicatorCommands(core, replicator);
useP2PReplicatorUI(core, core, replicator);
} }
); );
} }

View File

@@ -1,3 +1,4 @@
import type PouchDB from "pouchdb-core";
import { fireAndForget } from "octagonal-wheels/promises"; import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "../AbstractModule"; import { AbstractModule } from "../AbstractModule";
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";

View File

@@ -1,25 +0,0 @@
import { REMOTE_P2P, type RemoteDBSettings } from "../../lib/src/common/types";
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
import { AbstractModule } from "../AbstractModule";
import { LiveSyncTrysteroReplicator } from "../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
import type { LiveSyncCore } from "../../main";
// Note:
// This module registers only the `getNewReplicator` handler for the P2P replicator.
// `useP2PReplicator` (see P2PReplicatorCore.ts) already registers the same `getNewReplicator`
// handler internally, so this module is redundant in environments that call `useP2PReplicator`.
// Register this module only in environments that do NOT use `useP2PReplicator` (e.g. CLI).
// In other words: just resolving `getNewReplicator` via this module is all that is needed
// to satisfy what `useP2PReplicator` requires from the replicator service.
export class ModuleReplicatorP2P extends AbstractModule {
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
const settings = { ...this.settings, ...settingOverride };
if (settings.remoteType == REMOTE_P2P) {
return Promise.resolve(new LiveSyncTrysteroReplicator(this.core));
}
return Promise.resolve(false);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
}
}

View File

@@ -1,331 +0,0 @@
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { normalizePath } from "../../deps.ts";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
REMOTE_MINIO,
TweakValuesShouldMatchedTemplate,
type ObsidianLiveSyncSettings,
} from "../../lib/src/common/types.ts";
import { AbstractModule } from "../AbstractModule.ts";
import type { LiveSyncCore } from "../../main.ts";
import FetchEverything from "../features/SetupWizard/dialogs/FetchEverything.svelte";
import RebuildEverything from "../features/SetupWizard/dialogs/RebuildEverything.svelte";
import { extractObject } from "octagonal-wheels/object";
import { SvelteDialogManagerBase } from "@lib/UI/svelteDialog.ts";
import type { ServiceContext } from "@lib/services/base/ServiceBase.ts";
export class ModuleRedFlag extends AbstractModule {
async isFlagFileExist(path: string) {
const redflag = await this.core.storageAccess.isExists(normalizePath(path));
if (redflag) {
return true;
}
return false;
}
async deleteFlagFile(path: string) {
try {
const isFlagged = await this.core.storageAccess.isExists(normalizePath(path));
if (isFlagged) {
await this.core.storageAccess.delete(path, true);
}
} catch (ex) {
this._log(`Could not delete ${path}`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
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 cleanupRebuildFlag() {
await this.deleteFlagFile(FlagFilesOriginal.REBUILD_ALL);
await this.deleteFlagFile(FlagFilesHumanReadable.REBUILD_ALL);
}
async cleanupFetchAllFlag() {
await this.deleteFlagFile(FlagFilesOriginal.FETCH_ALL);
await this.deleteFlagFile(FlagFilesHumanReadable.FETCH_ALL);
}
// dialogManager = new SvelteDialogManagerBase(this.core);
get dialogManager(): SvelteDialogManagerBase<ServiceContext> {
return this.core.services.UI.dialogManager;
}
/**
* 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 configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.",
[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;
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
const makeLocalChunkBeforeSyncAvailable = this.settings.remoteType !== REMOTE_MINIO;
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: makeLocalChunkBeforeSyncAvailable,
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 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);
}
return true;
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
super.onBindFunction(core, services);
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
}
}

View File

@@ -2,9 +2,11 @@ import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { extractObject } from "octagonal-wheels/object"; import { extractObject } from "octagonal-wheels/object";
import { import {
TweakValuesShouldMatchedTemplate, TweakValuesShouldMatchedTemplate,
TweakValuesTemplate,
IncompatibleChanges, IncompatibleChanges,
confName, confName,
type TweakValues, type TweakValues,
type ObsidianLiveSyncSettings,
type RemoteDBSettings, type RemoteDBSettings,
IncompatibleChangesInSpecificPattern, IncompatibleChangesInSpecificPattern,
CompatibleButLossyChanges, CompatibleButLossyChanges,
@@ -14,8 +16,107 @@ import { AbstractModule } from "../AbstractModule.ts";
import { $msg } from "../../lib/src/common/i18n.ts"; import { $msg } from "../../lib/src/common/i18n.ts";
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts"; import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
import type { LiveSyncCore } from "../../main.ts"; import type { LiveSyncCore } from "../../main.ts";
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
function valueToString(value: any) {
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return `${value}`;
}
export class ModuleResolvingMismatchedTweaks extends AbstractModule { export class ModuleResolvingMismatchedTweaks extends AbstractModule {
private _hasNotifiedAutoAcceptCompatibleUndefined = false;
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
const items = Object.keys(
TweakValuesShouldMatchedTemplate
) as (keyof typeof TweakValuesShouldMatchedTemplate)[];
return items.filter((key) => current[key] !== preferred[key]);
}
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
const currentModified = current.tweakModified;
const preferredModified = preferred.tweakModified;
// debugger;
const hasCurrentModified = typeof currentModified === "number" && currentModified > 0;
const hasPreferredModified = typeof preferredModified === "number" && preferredModified > 0;
if (!hasCurrentModified && !hasPreferredModified) return "REMOTE";
if (!hasCurrentModified) return "REMOTE";
if (!hasPreferredModified) return "CURRENT";
if (preferredModified >= currentModified) return "REMOTE";
return "CURRENT";
}
private async _shouldAutoAcceptCompatibleLossy(
current: TweakValues,
preferred: Partial<TweakValues>,
mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
): Promise<"REMOTE" | "CURRENT" | undefined> {
if (mismatchedKeys.length === 0) return undefined;
const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
(key) => CompatibleButLossyChanges.indexOf(key) !== -1
);
if (!hasOnlyCompatibleLossyMismatches) return undefined;
if (this.settings.autoAcceptCompatibleTweak === undefined) {
if (this._hasNotifiedAutoAcceptCompatibleUndefined) {
return undefined;
}
this._hasNotifiedAutoAcceptCompatibleUndefined = true;
const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible");
const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible");
const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const;
const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined");
const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"),
timeout: 0,
defaultAction: CHOICE_ENABLE,
});
if (ret !== CHOICE_ENABLE) {
return undefined;
}
await this.services.setting.applyPartial(
{
autoAcceptCompatibleTweak: true,
},
true
);
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
}
if (this.settings.autoAcceptCompatibleTweak !== true) return undefined;
return this._selectNewerTweakSide(current, preferred);
}
/**
* Hook before saving settings, to check if there are changes in tweak values, and if so,
* update the tweakModified timestamp to current time.
* This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time.
* @param next
* @param previous
* @returns
*/
async _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings) {
const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[];
const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified");
const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]);
if (!hasChangedTweak) return;
Logger(
`Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}`
);
const modified = Date.now();
Logger(`Modified: ${modified}`);
return await Promise.resolve({
tweakModified: modified,
});
}
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> { async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false; if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false;
const preferred = this.core.replicator.preferredTweakValue; const preferred = this.core.replicator.preferredTweakValue;
@@ -26,10 +127,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
if (ret == "IGNORE") return true; if (ret == "IGNORE") return true;
} }
async _checkAndAskResolvingMismatchedTweaks( async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
preferred: Partial<TweakValues> const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
): Promise<[TweakValues | boolean, boolean]> { const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred);
const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings); const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys);
if (autoAcceptSide === "REMOTE") {
return [{ ...mine, ...preferred }, false];
}
if (autoAcceptSide === "CURRENT") {
return [true, false];
}
const items = Object.entries(TweakValuesShouldMatchedTemplate); const items = Object.entries(TweakValuesShouldMatchedTemplate);
let rebuildRequired = false; let rebuildRequired = false;
let rebuildRecommended = false; let rebuildRecommended = false;
@@ -68,8 +175,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
tableRows.push( tableRows.push(
$msg("TweakMismatchResolve.Table.Row", { $msg("TweakMismatchResolve.Table.Row", {
name: confName(key), name: confName(key),
self: valueMine, self: valueToString(valueMine),
remote: valuePreferred, remote: valueToString(valuePreferred),
}) })
); );
} }
@@ -136,9 +243,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
if (!tweaks) { if (!tweaks) {
return "IGNORE"; return "IGNORE";
} }
const preferred = extractObject(TweakValuesShouldMatchedTemplate, tweaks); const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks);
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferred);
if (!conf) return "IGNORE"; if (!conf) return "IGNORE";
if (conf === true) { if (conf === true) {
@@ -146,10 +251,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
if (rebuildRequired) { if (rebuildRequired) {
await this.core.rebuilder.$rebuildRemote(); await this.core.rebuilder.$rebuildRemote();
} }
Logger( Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE);
`Tweak values on the remote server have been updated. Your other device will see this message.`,
LOG_LEVEL_NOTICE
);
return "CHECKAGAIN"; return "CHECKAGAIN";
} }
if (conf) { if (conf) {
@@ -159,7 +261,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
if (rebuildRequired) { if (rebuildRequired) {
await this.core.rebuilder.$fetchLocal(); await this.core.rebuilder.$fetchLocal();
} }
Logger(`Configuration has been updated as configured by the other device.`, LOG_LEVEL_NOTICE); Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE);
return "CHECKAGAIN"; return "CHECKAGAIN";
} }
return "IGNORE"; return "IGNORE";
@@ -186,6 +288,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
async _checkAndAskUseRemoteConfiguration( async _checkAndAskUseRemoteConfiguration(
trialSetting: RemoteDBSettings trialSetting: RemoteDBSettings
): Promise<{ result: false | TweakValues; requireFetch: boolean }> { ): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
if (trialSetting.remoteType === REMOTE_P2P) {
return { result: false, requireFetch: false };
}
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting); const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
if (preferred) { if (preferred) {
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred); return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);
@@ -197,6 +302,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
trialSetting: RemoteDBSettings, trialSetting: RemoteDBSettings,
preferred: TweakValues preferred: TweakValues
): Promise<{ result: false | TweakValues; requireFetch: boolean }> { ): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred);
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys);
if (autoAcceptSide === "REMOTE") {
return { result: { ...trialSetting, ...preferred }, requireFetch: false };
}
if (autoAcceptSide === "CURRENT") {
return { result: false, requireFetch: false };
}
const items = Object.entries(TweakValuesShouldMatchedTemplate); const items = Object.entries(TweakValuesShouldMatchedTemplate);
let rebuildRequired = false; let rebuildRequired = false;
let rebuildRecommended = false; let rebuildRecommended = false;
@@ -207,8 +322,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
// const items = [mine,preferred] // const items = [mine,preferred]
for (const v of items) { for (const v of items) {
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate; const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
const remoteValueForDisplay = escapeMarkdownValue(preferred[key]); const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key]));
const currentValueForDisplay = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])}`; const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key]));
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) { if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
if (IncompatibleChanges.indexOf(key) !== -1) { if (IncompatibleChanges.indexOf(key) !== -1) {
rebuildRequired = true; rebuildRequired = true;
@@ -285,6 +400,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
} }
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.setting.onBeforeSaveSettingData.addHandler(this._onBeforeSaveSettingData.bind(this));
services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this)); services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this));
services.tweakValue.checkAndAskResolvingMismatched.setHandler( services.tweakValue.checkAndAskResolvingMismatched.setHandler(
this._checkAndAskResolvingMismatchedTweaks.bind(this) this._checkAndAskResolvingMismatchedTweaks.bind(this)

View File

@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@lib/common/types";
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn(async () => undefined);
const core = {
_services: {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
setting: {
saveSettingData: vi.fn(async () => undefined),
},
},
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
...settingsOverride,
},
confirm: {
askSelectStringDialogue,
},
} as any;
Object.defineProperty(core, "services", {
get() {
return core._services;
},
});
const module = new ModuleResolvingMismatchedTweaks(core);
return { module, core, askSelectStringDialogue };
}
describe("ModuleResolvingMismatchedTweaks", () => {
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: 100,
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: 200,
} as Partial<TweakValues>;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toEqual(preferred);
expect(rebuild).toBe(false);
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
encrypt: false,
tweakModified: 100,
});
const preferred = {
...(DEFAULT_SETTINGS as unknown as TweakValues),
hashAlg: "xxhash32",
encrypt: true,
tweakModified: 200,
} as Partial<TweakValues>;
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
expect(conf).toBe(false);
expect(rebuild).toBe(false);
expect(askSelectStringDialogue).toHaveBeenCalledTimes(1);
});
it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => {
const { module, askSelectStringDialogue } = createModule({
autoAcceptCompatibleTweak: true,
hashAlg: "xxhash64",
tweakModified: 300,
});
const trialSetting = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
hashAlg: "xxhash64",
tweakModified: 300,
} as RemoteDBSettings;
const preferred = {
...(trialSetting as unknown as TweakValues),
hashAlg: "xxhash32",
tweakModified: 200,
} as TweakValues;
const result = await module._askUseRemoteConfiguration(trialSetting, preferred);
expect(result).toEqual({ result: false, requireFetch: false });
expect(askSelectStringDialogue).not.toHaveBeenCalled();
});
});

View File

@@ -1,429 +0,0 @@
import { unique } from "octagonal-wheels/collection";
import { throttle } from "octagonal-wheels/function";
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts";
import { BASE_IS_NEW, EVEN, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts";
import {
type FilePathWithPrefixLC,
type FilePathWithPrefix,
type MetaEntry,
isMetaEntry,
type EntryDoc,
LOG_LEVEL_VERBOSE,
LOG_LEVEL_NOTICE,
LOG_LEVEL_INFO,
LOG_LEVEL_DEBUG,
type UXFileInfoStub,
type LOG_LEVEL,
} from "../../lib/src/common/types.ts";
import { isAnyNote } from "../../lib/src/common/utils.ts";
import { stripAllPrefixes } from "../../lib/src/string_and_binary/path.ts";
import { AbstractModule } from "../AbstractModule.ts";
import { withConcurrency } from "octagonal-wheels/iterable/map";
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
import type { LiveSyncCore } from "../../main.ts";
export class ModuleInitializerFile extends AbstractModule {
private _detectedErrors = new Set<string>();
private logDetectedError(message: string, logLevel: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) {
this._detectedErrors.add(message);
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
this._log(message, logLevel, key);
}
private resetDetectedError(message: string) {
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
this._detectedErrors.delete(message);
}
private async _performFullScan(showingNotice?: boolean, ignoreSuspending: boolean = false): Promise<boolean> {
this._log("Opening the key-value database", LOG_LEVEL_VERBOSE);
const isInitialized = (await this.core.kvDB.get<boolean>("initialized")) || false;
// synchronize all files between database and storage.
const ERR_NOT_CONFIGURED =
"LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented.";
if (!this.settings.isConfigured) {
this.logDetectedError(ERR_NOT_CONFIGURED, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
return false;
}
this.resetDetectedError(ERR_NOT_CONFIGURED);
const ERR_SUSPENDING =
"Now suspending file watching. Synchronising between the storage and the local database is now prevented.";
if (!ignoreSuspending && this.settings.suspendFileWatching) {
this.logDetectedError(ERR_SUSPENDING, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
return false;
}
const MSG_IN_REMEDIATION = `Started in remediation Mode! (Max mtime for reflect events is set). Synchronising between the storage and the local database is now prevented.`;
this.resetDetectedError(ERR_SUSPENDING);
if (this.settings.maxMTimeForReflectEvents > 0) {
this.logDetectedError(MSG_IN_REMEDIATION, LOG_LEVEL_NOTICE, "syncAll");
return false;
}
this.resetDetectedError(MSG_IN_REMEDIATION);
if (showingNotice) {
this._log("Initializing", LOG_LEVEL_NOTICE, "syncAll");
}
if (isInitialized) {
this._log("Restoring storage state", LOG_LEVEL_VERBOSE);
await this.core.storageAccess.restoreState();
}
this._log("Initialize and checking database files");
this._log("Checking deleted files");
await this.collectDeletedFiles();
this._log("Collecting local files on the storage", LOG_LEVEL_VERBOSE);
const filesStorageSrc = await this.core.storageAccess.getFiles();
const _filesStorage = [] as typeof filesStorageSrc;
for (const f of filesStorageSrc) {
if (await this.services.vault.isTargetFile(f.path)) {
_filesStorage.push(f);
}
}
const convertCase = <FilePathWithPrefix>(path: FilePathWithPrefix): FilePathWithPrefixLC => {
if (this.settings.handleFilenameCaseSensitive) {
return path as FilePathWithPrefixLC;
}
return (path as string).toLowerCase() as FilePathWithPrefixLC;
};
// If handleFilenameCaseSensitive is enabled, `FilePathWithPrefixLC` is the same as `FilePathWithPrefix`.
const storageFileNameMap = Object.fromEntries(
_filesStorage.map((e) => [e.path, e] as [FilePathWithPrefix, UXFileInfoStub])
);
const storageFileNames = Object.keys(storageFileNameMap) as FilePathWithPrefix[];
const storageFileNameCapsPair = storageFileNames.map(
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
);
// const storageFileNameCS2CI = Object.fromEntries(storageFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
const storageFileNameCI2CS = Object.fromEntries(storageFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
FilePathWithPrefixLC,
FilePathWithPrefix
>;
this._log("Collecting local files on the DB", LOG_LEVEL_VERBOSE);
const _DBEntries = [] as MetaEntry[];
let count = 0;
// Fetch all documents from the database (including conflicts to prevent overwriting).
for await (const doc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
count++;
if (count % 25 == 0)
this._log(
`Collecting local files on the DB: ${count}`,
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
"syncAll"
);
const path = this.getPath(doc);
if (isValidPath(path) && (await this.services.vault.isTargetFile(path))) {
if (!isMetaEntry(doc)) {
this._log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
continue;
}
_DBEntries.push(doc);
}
}
const databaseFileNameMap = Object.fromEntries(
_DBEntries.map((e) => [this.getPath(e), e] as [FilePathWithPrefix, MetaEntry])
);
const databaseFileNames = Object.keys(databaseFileNameMap) as FilePathWithPrefix[];
const databaseFileNameCapsPair = databaseFileNames.map(
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
);
// const databaseFileNameCS2CI = Object.fromEntries(databaseFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
FilePathWithPrefix,
FilePathWithPrefixLC
>;
const allFiles = unique([
...Object.keys(databaseFileNameCI2CS),
...Object.keys(storageFileNameCI2CS),
]) as FilePathWithPrefixLC[];
this._log(`Total files in the database: ${databaseFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
this._log(`Total files in the storage: ${storageFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
this._log(`Total files: ${allFiles.length}`, LOG_LEVEL_VERBOSE, "syncAll");
const filesExistOnlyInStorage = allFiles.filter((e) => !databaseFileNameCI2CS[e]);
const filesExistOnlyInDatabase = allFiles.filter((e) => !storageFileNameCI2CS[e]);
const filesExistBoth = allFiles.filter((e) => databaseFileNameCI2CS[e] && storageFileNameCI2CS[e]);
this._log(`Files exist only in storage: ${filesExistOnlyInStorage.length}`, LOG_LEVEL_VERBOSE, "syncAll");
this._log(`Files exist only in database: ${filesExistOnlyInDatabase.length}`, LOG_LEVEL_VERBOSE, "syncAll");
this._log(`Files exist both in storage and database: ${filesExistBoth.length}`, LOG_LEVEL_VERBOSE, "syncAll");
this._log("Synchronising...");
const processStatus = {} as Record<string, string>;
const logLevel = showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
const updateLog = throttle((key: string, msg: string) => {
processStatus[key] = msg;
const log = Object.values(processStatus).join("\n");
this._log(log, logLevel, "syncAll");
}, 25);
const initProcess = [];
const runAll = async <T>(procedureName: string, objects: T[], callback: (arg: T) => Promise<void>) => {
if (objects.length == 0) {
this._log(`${procedureName}: Nothing to do`);
return;
}
this._log(procedureName);
if (!this.localDatabase.isReady) throw Error("Database is not ready!");
let success = 0;
let failed = 0;
let total = 0;
for await (const result of withConcurrency(
objects,
async (e) => {
try {
await callback(e);
return true;
} catch (ex) {
this._log(`Error while ${procedureName}`, LOG_LEVEL_NOTICE);
this._log(ex, LOG_LEVEL_VERBOSE);
return false;
}
},
10
)) {
if (result) {
success++;
} else {
failed++;
}
total++;
const msg = `${procedureName}: DONE:${success}, FAILED:${failed}, LAST:${objects.length - total}`;
updateLog(procedureName, msg);
}
const msg = `${procedureName} All done: DONE:${success}, FAILED:${failed}`;
updateLog(procedureName, msg);
};
initProcess.push(
runAll("UPDATE DATABASE", filesExistOnlyInStorage, async (e) => {
// Exists in storage but not in database.
const file = storageFileNameMap[storageFileNameCI2CS[e]];
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
const path = file.path;
await this.core.fileHandler.storeFileToDB(file);
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(path, true));
eventHub.emitEvent("event-file-changed", { file: path, automated: true });
} else {
this._log(`UPDATE DATABASE: ${e} has been skipped due to file size exceeding the limit`, logLevel);
}
})
);
initProcess.push(
runAll("UPDATE STORAGE", filesExistOnlyInDatabase, async (e) => {
const w = databaseFileNameMap[databaseFileNameCI2CS[e]];
// Exists in database but not in storage.
const path = this.getPath(w) ?? e;
if (w && !(w.deleted || w._deleted)) {
if (!this.services.vault.isFileSizeTooLarge(w.size)) {
// Prevent applying the conflicted state to the storage.
if (w._conflicts?.length ?? 0 > 0) {
this._log(`UPDATE STORAGE: ${path} has conflicts. skipped (x)`, LOG_LEVEL_INFO);
return;
}
// await this.pullFile(path, undefined, false, undefined, false);
// Memo: No need to force
await this.core.fileHandler.dbToStorage(path, null, true);
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(e, true));
eventHub.emitEvent("event-file-changed", {
file: e,
automated: true,
});
this._log(`Check or pull from db:${path} OK`);
} else {
this._log(
`UPDATE STORAGE: ${path} has been skipped due to file size exceeding the limit`,
logLevel
);
}
} else if (w) {
this._log(`Deletion history skipped: ${path}`, LOG_LEVEL_VERBOSE);
} else {
this._log(`entry not found: ${path}`);
}
})
);
const fileMap = filesExistBoth.map((path) => {
const file = storageFileNameMap[storageFileNameCI2CS[path]];
const doc = databaseFileNameMap[databaseFileNameCI2CS[path]];
return { file, doc };
});
initProcess.push(
runAll("SYNC DATABASE AND STORAGE", fileMap, async (e) => {
const { file, doc } = e;
// Prevent applying the conflicted state to the storage.
if (doc._conflicts?.length ?? 0 > 0) {
this._log(`SYNC DATABASE AND STORAGE: ${file.path} has conflicts. skipped`, LOG_LEVEL_INFO);
return;
}
if (
!this.services.vault.isFileSizeTooLarge(file.stat.size) &&
!this.services.vault.isFileSizeTooLarge(doc.size)
) {
await this.syncFileBetweenDBandStorage(file, doc);
} else {
this._log(
`SYNC DATABASE AND STORAGE: ${this.getPath(doc)} has been skipped due to file size exceeding the limit`,
logLevel
);
}
})
);
await Promise.all(initProcess);
// this.setStatusBarText(`NOW TRACKING!`);
this._log("Initialized, NOW TRACKING!");
if (!isInitialized) {
await this.core.kvDB.set("initialized", true);
}
if (showingNotice) {
this._log("Initialize done!", LOG_LEVEL_NOTICE, "syncAll");
}
return true;
}
async syncFileBetweenDBandStorage(file: UXFileInfoStub, doc: MetaEntry) {
if (!doc) {
throw new Error(`Missing doc:${(file as any).path}`);
}
if ("path" in file) {
const w = await this.core.storageAccess.getFileStub((file as any).path);
if (w) {
file = w;
} else {
throw new Error(`Missing file:${(file as any).path}`);
}
}
const compareResult = this.services.path.compareFileFreshness(file, doc);
switch (compareResult) {
case BASE_IS_NEW:
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
this._log("STORAGE -> DB :" + file.path);
await this.core.fileHandler.storeFileToDB(file);
} else {
this._log(
`STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`,
LOG_LEVEL_NOTICE
);
}
break;
case TARGET_IS_NEW:
if (!this.services.vault.isFileSizeTooLarge(doc.size)) {
this._log("STORAGE <- DB :" + file.path);
if (await this.core.fileHandler.dbToStorage(doc, stripAllPrefixes(file.path), true)) {
eventHub.emitEvent("event-file-changed", {
file: file.path,
automated: true,
});
} else {
this._log(`STORAGE <- DB : Cloud not read ${file.path}, possibly deleted`, LOG_LEVEL_NOTICE);
}
return caches;
} else {
this._log(
`STORAGE <- DB : ${file.path} has been skipped due to file size exceeding the limit`,
LOG_LEVEL_NOTICE
);
}
break;
case EVEN:
this._log("STORAGE == DB :" + file.path + "", LOG_LEVEL_DEBUG);
break;
default:
this._log("STORAGE ?? DB :" + file.path + " Something got weird");
}
}
// This method uses an old version of database accessor, which is not recommended.
// TODO: Fix
async collectDeletedFiles() {
const limitDays = this.settings.automaticallyDeleteMetadataOfDeletedFiles;
if (limitDays <= 0) return;
this._log(`Checking expired file history`);
const limit = Date.now() - 86400 * 1000 * limitDays;
const notes: {
path: string;
mtime: number;
ttl: number;
doc: PouchDB.Core.ExistingDocument<EntryDoc & PouchDB.Core.AllDocsMeta>;
}[] = [];
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
if (isAnyNote(doc)) {
if (doc.deleted && doc.mtime - limit < 0) {
notes.push({
path: this.getPath(doc),
mtime: doc.mtime,
ttl: (doc.mtime - limit) / 1000 / 86400,
doc: doc,
});
}
}
}
if (notes.length == 0) {
this._log("There are no old documents");
this._log(`Checking expired file history done`);
return;
}
for (const v of notes) {
this._log(`Deletion history expired: ${v.path}`);
const delDoc = v.doc;
delDoc._deleted = true;
await this.localDatabase.putRaw(delDoc);
}
this._log(`Checking expired file history done`);
}
private async _initializeDatabase(
showingNotice: boolean = false,
reopenDatabase = true,
ignoreSuspending: boolean = false
): Promise<boolean> {
this.services.appLifecycle.resetIsReady();
if (
!reopenDatabase ||
(await this.services.database.openDatabase({
databaseEvents: this.services.databaseEvents,
replicator: this.services.replicator,
}))
) {
if (this.localDatabase.isReady) {
await this.services.vault.scanVault(showingNotice, ignoreSuspending);
}
const ERR_INITIALISATION_FAILED = `Initializing database has been failed on some module!`;
if (!(await this.services.databaseEvents.onDatabaseInitialised(showingNotice))) {
this.logDetectedError(ERR_INITIALISATION_FAILED, LOG_LEVEL_NOTICE);
return false;
}
this.resetDetectedError(ERR_INITIALISATION_FAILED);
this.services.appLifecycle.markIsReady();
// run queued event once.
await this.services.fileProcessing.commitPendingFileEvents();
return true;
} else {
this.services.appLifecycle.resetIsReady();
return false;
}
}
private _reportDetectedErrors(): Promise<string[]> {
return Promise.resolve(Array.from(this._detectedErrors));
}
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
services.appLifecycle.getUnresolvedMessages.addHandler(this._reportDetectedErrors.bind(this));
services.databaseEvents.initialiseDatabase.addHandler(this._initializeDatabase.bind(this));
services.vault.scanVault.addHandler(this._performFullScan.bind(this));
}
}

View File

@@ -1,135 +0,0 @@
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { sizeToHumanReadable } from "octagonal-wheels/number";
import { $msg } from "src/lib/src/common/i18n.ts";
import type { LiveSyncCore } from "../../main.ts";
import { EVENT_REQUEST_CHECK_REMOTE_SIZE, eventHub } from "@/common/events.ts";
import { AbstractModule } from "../AbstractModule.ts";
export class ModuleCheckRemoteSize extends AbstractModule {
checkRemoteSize(): Promise<boolean> {
this.settings.notifyThresholdOfRemoteStorageSize = 1;
return this._allScanStat();
}
private async _allScanStat(): Promise<boolean> {
if (this.services.API.isOnline === false) {
this._log("Network is offline, skipping remote size check.", LOG_LEVEL_INFO);
return true;
}
this._log($msg("moduleCheckRemoteSize.logCheckingStorageSizes"), LOG_LEVEL_VERBOSE);
if (this.settings.notifyThresholdOfRemoteStorageSize < 0) {
const message = $msg("moduleCheckRemoteSize.msgSetDBCapacity");
const ANSWER_0 = $msg("moduleCheckRemoteSize.optionNoWarn");
const ANSWER_800 = $msg("moduleCheckRemoteSize.option800MB");
const ANSWER_2000 = $msg("moduleCheckRemoteSize.option2GB");
const ASK_ME_NEXT_TIME = $msg("moduleCheckRemoteSize.optionAskMeLater");
const ret = await this.core.confirm.askSelectStringDialogue(
message,
[ANSWER_0, ANSWER_800, ANSWER_2000, ASK_ME_NEXT_TIME],
{
defaultAction: ASK_ME_NEXT_TIME,
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeNotify"),
timeout: 40,
}
);
if (ret == ANSWER_0) {
this.settings.notifyThresholdOfRemoteStorageSize = 0;
await this.saveSettings();
} else if (ret == ANSWER_800) {
this.settings.notifyThresholdOfRemoteStorageSize = 800;
await this.saveSettings();
} else if (ret == ANSWER_2000) {
this.settings.notifyThresholdOfRemoteStorageSize = 2000;
await this.saveSettings();
}
}
if (this.settings.notifyThresholdOfRemoteStorageSize > 0) {
const remoteStat = await this.core.replicator?.getRemoteStatus(this.settings);
if (remoteStat) {
const estimatedSize = remoteStat.estimatedSize;
if (estimatedSize) {
const maxSize = this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024;
if (estimatedSize > maxSize) {
const message = $msg("moduleCheckRemoteSize.msgDatabaseGrowing", {
estimatedSize: sizeToHumanReadable(estimatedSize),
maxSize: sizeToHumanReadable(maxSize),
});
const newMax = ~~(estimatedSize / 1024 / 1024) + 100;
const ANSWER_ENLARGE_LIMIT = $msg("moduleCheckRemoteSize.optionIncreaseLimit", {
newMax: newMax.toString(),
});
const ANSWER_REBUILD = $msg("moduleCheckRemoteSize.optionRebuildAll");
const ANSWER_IGNORE = $msg("moduleCheckRemoteSize.optionDismiss");
const ret = await this.core.confirm.askSelectStringDialogue(
message,
[ANSWER_ENLARGE_LIMIT, ANSWER_REBUILD, ANSWER_IGNORE],
{
defaultAction: ANSWER_IGNORE,
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded"),
timeout: 60,
}
);
if (ret == ANSWER_REBUILD) {
const ret = await this.core.confirm.askYesNoDialog(
$msg("moduleCheckRemoteSize.msgConfirmRebuild"),
{ defaultOption: "No" }
);
if (ret == "yes") {
this.core.settings.notifyThresholdOfRemoteStorageSize = -1;
await this.saveSettings();
await this.core.rebuilder.scheduleRebuild();
}
} else if (ret == ANSWER_ENLARGE_LIMIT) {
this.settings.notifyThresholdOfRemoteStorageSize = ~~(estimatedSize / 1024 / 1024) + 100;
this._log(
$msg("moduleCheckRemoteSize.logThresholdEnlarged", {
size: this.settings.notifyThresholdOfRemoteStorageSize.toString(),
}),
LOG_LEVEL_NOTICE
);
// await this.core.saveSettings();
await this.core.services.setting.saveSettingData();
} else {
// Dismiss or Close the dialog
}
this._log(
$msg("moduleCheckRemoteSize.logExceededWarning", {
measuredSize: sizeToHumanReadable(estimatedSize),
notifySize: sizeToHumanReadable(
this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024
),
}),
LOG_LEVEL_INFO
);
} else {
this._log(
$msg("moduleCheckRemoteSize.logCurrentStorageSize", {
measuredSize: sizeToHumanReadable(estimatedSize),
}),
LOG_LEVEL_INFO
);
}
}
}
}
return true;
}
private _everyOnloadStart(): Promise<boolean> {
this.addCommand({
id: "livesync-reset-remote-size-threshold-and-check",
name: "Reset notification threshold and check the remote database usage",
callback: async () => {
await this.checkRemoteSize();
},
});
eventHub.onEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE, () => this.checkRemoteSize());
return Promise.resolve(true);
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
}
}

View File

@@ -121,7 +121,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
return; return;
} }
const isHidden = document.hidden; const isHidden = activeWindow.document.hidden;
if (this.isLastHidden === isHidden) { if (this.isLastHidden === isHidden) {
return; return;
} }
@@ -134,7 +134,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
} else { } else {
// suspend all temporary. // suspend all temporary.
if (this.services.appLifecycle.isSuspended()) return; if (this.services.appLifecycle.isSuspended()) return;
if (!this.hasFocus) return; // Do not block resume by focus state here; visibility recovery should be enough.
await this.services.appLifecycle.onResuming(); await this.services.appLifecycle.onResuming();
await this.services.appLifecycle.onResumed(); await this.services.appLifecycle.onResumed();
} }

View File

@@ -1,6 +1,6 @@
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts"; import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts";
import { getPathFromTFile, isValidPath } from "../../../common/utils.ts"; import { getPathFromTFile, isValidPath } from "../../../common/utils.ts";
import { decodeBinary, escapeStringToHTML, readString } from "../../../lib/src/string_and_binary/convert.ts"; import { decodeBinary, readString } from "../../../lib/src/string_and_binary/convert.ts";
import ObsidianLiveSyncPlugin from "../../../main.ts"; import ObsidianLiveSyncPlugin from "../../../main.ts";
import { import {
type DocumentID, type DocumentID,
@@ -145,22 +145,66 @@ export class DocumentHistoryModal extends Modal {
return v; return v;
} }
prepareContentView(usePreformatted = true) {
this.contentView.empty();
this.contentView.toggleClass("op-pre", usePreformatted);
}
appendTextDiff(diff: [number, string][]) {
for (const [operation, text] of diff) {
if (operation == DIFF_DELETE) {
this.contentView.createSpan({ text, cls: "history-deleted" });
} else if (operation == DIFF_EQUAL) {
this.contentView.createSpan({ text, cls: "history-normal" });
} else if (operation == DIFF_INSERT) {
this.contentView.createSpan({ text, cls: "history-added" });
}
}
}
appendImageDiff(baseSrc: string, overlaySrc?: string) {
const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" });
const overlay = wrap.createDiv({ cls: "overlay" });
overlay.createEl("img", { cls: "img-base" }, (img) => {
img.src = baseSrc;
});
if (overlaySrc) {
overlay.createEl("img", { cls: "img-overlay" }, (img) => {
img.src = overlaySrc;
});
}
}
appendDeletedNotice(usePreformatted = true) {
const notice = "(At this revision, the file has been deleted)";
if (usePreformatted) {
this.contentView.appendText(`${notice}\n`);
} else {
this.contentView.createDiv({ text: notice });
}
}
async showExactRev(rev: string) { async showExactRev(rev: string) {
const db = this.core.localDatabase; const db = this.core.localDatabase;
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true); const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
this.currentText = ""; this.currentText = "";
this.currentDeleted = false; this.currentDeleted = false;
this.prepareContentView();
if (w === false) { if (w === false) {
this.currentDeleted = true; this.currentDeleted = true;
this.info.innerHTML = ""; this.info.empty();
this.contentView.innerHTML = `Could not read this revision<br>(${rev})`; this.contentView.appendText("Could not read this revision");
this.contentView.createEl("br");
this.contentView.appendText(`(${rev})`);
} else { } else {
this.currentDoc = w; this.currentDoc = w;
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`; this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`);
let result = undefined;
const w1data = readDocument(w); const w1data = readDocument(w);
this.currentDeleted = !!w.deleted; this.currentDeleted = !!w.deleted;
// this.currentText = w1data; if (typeof w1data == "string") {
this.currentText = w1data;
}
let rendered = false;
if (this.showDiff) { if (this.showDiff) {
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1); const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) { if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
@@ -168,58 +212,55 @@ export class DocumentHistoryModal extends Modal {
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true); const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
if (w2 != false) { if (w2 != false) {
if (typeof w1data == "string") { if (typeof w1data == "string") {
result = ""; const w2data = readDocument(w2);
if (typeof w2data == "string") {
const dmp = new diff_match_patch(); const dmp = new diff_match_patch();
const w2data = readDocument(w2) as string;
const diff = dmp.diff_main(w2data, w1data); const diff = dmp.diff_main(w2data, w1data);
dmp.diff_cleanupSemantic(diff); dmp.diff_cleanupSemantic(diff);
for (const v of diff) { if (this.currentDeleted) {
const x1 = v[0]; this.appendDeletedNotice();
const x2 = v[1];
if (x1 == DIFF_DELETE) {
result += "<span class='history-deleted'>" + escapeStringToHTML(x2) + "</span>";
} else if (x1 == DIFF_EQUAL) {
result += "<span class='history-normal'>" + escapeStringToHTML(x2) + "</span>";
} else if (x1 == DIFF_INSERT) {
result += "<span class='history-added'>" + escapeStringToHTML(x2) + "</span>";
} }
this.appendTextDiff(diff);
rendered = true;
} }
result = result.replace(/\n/g, "<br>");
} else if (isImage(this.file)) { } else if (isImage(this.file)) {
const src = this.generateBlobURL("base", w1data); const src = this.generateBlobURL("base", w1data);
const overlay = this.generateBlobURL( const overlay = this.generateBlobURL(
"overlay", "overlay",
readDocument(w2) as Uint8Array<ArrayBuffer> readDocument(w2) as Uint8Array<ArrayBuffer>
); );
result = `<div class='ls-imgdiff-wrap'> this.prepareContentView(false);
<div class='overlay'> if (this.currentDeleted) {
<img class='img-base' src="${src}"> this.appendDeletedNotice(false);
<img class='img-overlay' src='${overlay}'> }
</div> this.appendImageDiff(src, overlay);
</div>`; rendered = true;
this.contentView.removeClass("op-pre");
} }
} }
} }
} }
if (result == undefined) { if (!rendered) {
if (typeof w1data != "string") { if (typeof w1data != "string") {
if (isImage(this.file)) { if (isImage(this.file)) {
const src = this.generateBlobURL("base", w1data); const src = this.generateBlobURL("base", w1data);
result = `<div class='ls-imgdiff-wrap'> this.prepareContentView(false);
<div class='overlay'> if (this.currentDeleted) {
<img class='img-base' src="${src}"> this.appendDeletedNotice(false);
</div> }
</div>`; this.appendImageDiff(src);
this.contentView.removeClass("op-pre"); } else {
if (this.currentDeleted) {
this.appendDeletedNotice();
}
this.contentView.appendText("Binary file");
} }
} else { } else {
result = escapeStringToHTML(w1data); if (this.currentDeleted) {
this.appendDeletedNotice();
}
this.contentView.appendText(w1data);
} }
} }
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
this.contentView.innerHTML =
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
} }
// Reset diff navigation after content changes // Reset diff navigation after content changes
this.resetDiffNavigation(); this.resetDiffNavigation();
@@ -245,8 +286,7 @@ export class DocumentHistoryModal extends Modal {
if (direction === "next") { if (direction === "next") {
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length; this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
} else { } else {
this.currentDiffIndex = this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
} }
const target = diffElements[this.currentDiffIndex]; const target = diffElements[this.currentDiffIndex];

View File

@@ -1,7 +1,6 @@
import { App, Modal } from "../../../deps.ts"; import { App, Modal } from "../../../deps.ts";
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch"; import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts"; import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts";
import { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
import { delay } from "../../../lib/src/common/utils.ts"; import { delay } from "../../../lib/src/common/utils.ts";
import { eventHub } from "../../../common/events.ts"; import { eventHub } from "../../../common/events.ts";
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts"; import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
@@ -44,6 +43,25 @@ export class ConflictResolveModal extends Modal {
// sendValue("close-resolve-conflict:" + this.filename, false); // sendValue("close-resolve-conflict:" + this.filename, false);
} }
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
const lines = text.split("\n");
lines.forEach((line, index) => {
const span = container.createSpan({ cls });
span.textContent = line;
if (index < lines.length - 1) {
container.createSpan({ cls: "ls-mark-cr" });
container.createEl("br");
}
});
}
appendVersionInfo(container: HTMLDivElement, cls: string, name: string, date: string) {
const line = container.createSpan({ cls });
line.createSpan({ text: name, cls: "conflict-dev-name" });
line.appendText(`: ${date}`);
container.createEl("br");
}
override onOpen() { override onOpen() {
const { contentEl } = this; const { contentEl } = this;
// Send cancel signal for the previous merge dialogue // Send cancel signal for the previous merge dialogue
@@ -64,25 +82,21 @@ export class ConflictResolveModal extends Modal {
const div = contentEl.createDiv(""); const div = contentEl.createDiv("");
div.addClass("op-scrollable"); div.addClass("op-scrollable");
div.addClass("ls-dialog"); div.addClass("ls-dialog");
let diff = ""; let diffLength = 0;
for (const v of this.result.diff) { for (const v of this.result.diff) {
const x1 = v[0]; const x1 = v[0];
const x2 = v[1]; const x2 = v[1];
diffLength += x2.length;
if (diffLength > 100 * 1024) {
continue;
}
if (x1 == DIFF_DELETE) { if (x1 == DIFF_DELETE) {
diff += this.appendDiffFragment(div, x2, "deleted");
"<span class='deleted'>" + div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
"</span>";
} else if (x1 == DIFF_EQUAL) { } else if (x1 == DIFF_EQUAL) {
diff += this.appendDiffFragment(div, x2, "normal");
"<span class='normal'>" +
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
"</span>";
} else if (x1 == DIFF_INSERT) { } else if (x1 == DIFF_INSERT) {
diff += this.appendDiffFragment(div, x2, "added");
"<span class='added'>" +
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
"</span>";
} }
} }
@@ -92,8 +106,8 @@ export class ConflictResolveModal extends Modal {
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : ""); new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
const date2 = const date2 =
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : ""); new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
div2.innerHTML = `<span class='deleted'><span class='conflict-dev-name'>${this.localName}</span>: ${date1}</span><br> this.appendVersionInfo(div2, "deleted", this.localName, date1);
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>`; this.appendVersionInfo(div2, "added", this.remoteName, date2);
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
e.addEventListener("click", () => this.sendResponse(this.result.right.rev)) e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
).style.marginRight = "4px"; ).style.marginRight = "4px";
@@ -108,11 +122,9 @@ export class ConflictResolveModal extends Modal {
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
e.addEventListener("click", () => this.sendResponse(CANCELLED)) e.addEventListener("click", () => this.sendResponse(CANCELLED))
).style.marginRight = "4px"; ).style.marginRight = "4px";
diff = diff.replace(/\n/g, "<br>"); if (diffLength > 100 * 1024) {
if (diff.length > 100 * 1024) { div.empty();
div.innerText = "(Too large diff to display)"; div.innerText = "(Too large diff to display)";
} else {
div.innerHTML = diff;
} }
} }

View File

@@ -25,7 +25,7 @@ import {
EVENT_ON_UNRESOLVED_ERROR, EVENT_ON_UNRESOLVED_ERROR,
} from "../../common/events.ts"; } from "../../common/events.ts";
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { addIcon, normalizePath, Notice } from "../../deps.ts"; import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type WorkspaceLeaf } from "../../deps.ts";
import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger"; import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts"; import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
import { serialized } from "octagonal-wheels/concurrency/lock"; import { serialized } from "octagonal-wheels/concurrency/lock";
@@ -41,6 +41,8 @@ import {
} from "@lib/string_and_binary/path.ts"; } from "@lib/string_and_binary/path.ts";
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts"; import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts";
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts"; import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
import { generateReport } from "@/common/reportTool.ts";
// This module cannot be a core module because it depends on the Obsidian UI. // This module cannot be a core module because it depends on the Obsidian UI.
@@ -50,18 +52,51 @@ const globalLogFunction = (message: any, level?: number, key?: string) => {
const messageX = const messageX =
message instanceof Error message instanceof Error
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message }) ? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
: message; : typeof message === "string"
? message
: JSON.stringify(message);
const entry = { message: messageX, level, key } as LogEntry; const entry = { message: messageX, level, key } as LogEntry;
recentLogEntries.value = [...recentLogEntries.value, entry]; recentLogEntries.value = [...recentLogEntries.value, entry];
}; };
setGlobalLogFunction(globalLogFunction); setGlobalLogFunction(globalLogFunction);
let recentLogs = [] as string[]; // Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
const logForDump = [] as string[];
function addLog(log: string) { function addLog(log: string) {
recentLogs = [...recentLogs, log].splice(-200); logForDump.push(log);
logMessages.value = recentLogs; while (logForDump.length > 1000) {
logForDump.shift();
}
} }
// Display log is kept separate from the full log history to optimize performance and memory usage.
// And debounce the updates to the display log to avoid excessive UI updates when there are many log entries in a short time.
const logForDisplay = [] as string[];
const updateLogMessage = debounce(() => {
logMessages.value = [...logForDisplay];
}, 25);
function addDisplayLog(log: string) {
logForDisplay.push(log);
while (logForDisplay.length > 200) {
logForDisplay.shift();
}
updateLogMessage();
}
const redactPatterns = [/PBKDF2 salt \(Security Seed\):.*$/];
function redactLog(log: string) {
let redactedLog = log;
for (const pattern of redactPatterns) {
redactedLog = redactedLog.replace(pattern, (match) => {
return match.split(":")[0] + ": [REDACTED]";
});
}
return redactedLog;
}
// logStore.intercept(e => e.slice(Math.min(e.length - 200, 0))); // logStore.intercept(e => e.slice(Math.min(e.length - 200, 0)));
const showDebugLog = false; const showDebugLog = false;
@@ -86,15 +121,15 @@ export class ModuleLog extends AbstractObsidianModule {
// const emptyMark = `\u{2003}`; // const emptyMark = `\u{2003}`;
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) { function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) {
const formatted = reactiveSource(""); const formatted = reactiveSource("");
let timer: ReturnType<typeof setTimeout> | undefined = undefined; let timer: number | undefined = undefined;
let maxLen = 1; let maxLen = 1;
numI.onChanged((numX) => { numI.onChanged((numX) => {
const num = numX.value; const num = numX.value;
const numLen = `${Math.abs(num)}`.length + 1; const numLen = `${Math.abs(num)}`.length + 1;
maxLen = maxLen < numLen ? numLen : maxLen; maxLen = maxLen < numLen ? numLen : maxLen;
if (timer) clearTimeout(timer); if (timer) compatGlobal.clearTimeout(timer);
if (num == 0) { if (num == 0) {
timer = setTimeout(() => { timer = compatGlobal.setTimeout(() => {
formatted.value = ""; formatted.value = "";
maxLen = 1; maxLen = 1;
}, 3000); }, 3000);
@@ -323,7 +358,7 @@ export class ModuleLog extends AbstractObsidianModule {
if (this.nextFrameQueue) { if (this.nextFrameQueue) {
return; return;
} }
this.nextFrameQueue = requestAnimationFrame(() => { this.nextFrameQueue = compatGlobal.requestAnimationFrame(() => {
this.nextFrameQueue = undefined; this.nextFrameQueue = undefined;
const { message, status } = this.statusBarLabels.value; const { message, status } = this.statusBarLabels.value;
// const recent = logMessages.value; // const recent = logMessages.value;
@@ -346,7 +381,8 @@ export class ModuleLog extends AbstractObsidianModule {
(a, b) => (a < b.ttl ? a : b.ttl), (a, b) => (a < b.ttl ? a : b.ttl),
Number.MAX_SAFE_INTEGER Number.MAX_SAFE_INTEGER
); );
if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now); if (this.logLines.length > 0)
compatGlobal.setTimeout(() => this.applyStatusBarText(), minimumNext - now);
const recent = this.logLines.map((e) => e.message); const recent = this.logLines.map((e) => e.message);
const recentLogs = recent.reverse().join("\n"); const recentLogs = recent.reverse().join("\n");
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs; if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
@@ -368,7 +404,7 @@ export class ModuleLog extends AbstractObsidianModule {
if (this.statusDiv) { if (this.statusDiv) {
this.statusDiv.remove(); this.statusDiv.remove();
} }
document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove()); compatGlobal.document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove());
return Promise.resolve(true); return Promise.resolve(true);
} }
_everyOnloadStart(): Promise<boolean> { _everyOnloadStart(): Promise<boolean> {
@@ -390,7 +426,28 @@ export class ModuleLog extends AbstractObsidianModule {
void this.services.API.showWindow(VIEW_TYPE_LOG); void this.services.API.showWindow(VIEW_TYPE_LOG);
}, },
}); });
this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin)); this.addCommand({
id: "dump-debug-info",
name: "Generate full report for opening the issue with debug info",
callback: async () => {
const recentLog = [...logForDump];
const report = await generateReport(this.services.setting.currentSettings(), this.core);
const info = {
...report,
recentLog: recentLog.map(redactLog),
};
const yaml = `\`\`\`\`
# ---- Debug Info Dump ----
${stringifyYaml(info)}
\`\`\`\``;
if (await this.services.UI.promptCopyToClipboard("Debug info", yaml)) {
new Notice(
"Debug info copied to clipboard. You can paste it in the issue. Be careful as it may contain sensitive information, review it before sharing."
);
}
},
});
this.registerView(VIEW_TYPE_LOG, (leaf: WorkspaceLeaf) => new LogPaneView(leaf, this.plugin));
return Promise.resolve(true); return Promise.resolve(true);
} }
private _everyOnloadAfterLoadSettings(): Promise<boolean> { private _everyOnloadAfterLoadSettings(): Promise<boolean> {
@@ -404,7 +461,7 @@ export class ModuleLog extends AbstractObsidianModule {
void this.setFileStatus(); void this.setFileStatus();
}); });
const w = document.querySelectorAll(`.livesync-status`); const w = compatGlobal.document.querySelectorAll(`.livesync-status`);
w.forEach((e) => e.remove()); w.forEach((e) => e.remove());
this.observeForLogs(); this.observeForLogs();
@@ -421,6 +478,8 @@ export class ModuleLog extends AbstractObsidianModule {
this.statusBar?.addClass("syncstatusbar"); this.statusBar?.addClass("syncstatusbar");
} }
this.adjustStatusDivPosition(); this.adjustStatusDivPosition();
this._log("Log module loaded", LOG_LEVEL_INFO);
this._log("Verbose log", LOG_LEVEL_VERBOSE);
return Promise.resolve(true); return Promise.resolve(true);
} }
@@ -444,11 +503,12 @@ export class ModuleLog extends AbstractObsidianModule {
if (level == LOG_LEVEL_DEBUG && !showDebugLog) { if (level == LOG_LEVEL_DEBUG && !showDebugLog) {
return; return;
} }
let memoOnly = false;
if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) { if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) {
return; memoOnly = true;
} }
if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) { if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) {
return; memoOnly = true;
} }
const vaultName = this.services.vault.getVaultName(); const vaultName = this.services.vault.getVaultName();
const now = new Date(); const now = new Date();
@@ -469,6 +529,15 @@ export class ModuleLog extends AbstractObsidianModule {
? `${errorInfo}` ? `${errorInfo}`
: JSON.stringify(message, null, 2); : JSON.stringify(message, null, 2);
const newMessage = timestamp + "->" + messageContent; const newMessage = timestamp + "->" + messageContent;
if (this.settings?.writeLogToTheFile) {
this.writeLogToTheFile(now, vaultName, newMessage);
}
addLog(newMessage);
if (memoOnly) {
return;
}
addDisplayLog(newMessage);
if (message instanceof Error) { if (message instanceof Error) {
console.error(vaultName + ":" + newMessage); console.error(vaultName + ":" + newMessage);
} else if (level >= LOG_LEVEL_INFO) { } else if (level >= LOG_LEVEL_INFO) {
@@ -479,10 +548,6 @@ export class ModuleLog extends AbstractObsidianModule {
if (!this.settings?.showOnlyIconsOnEditor) { if (!this.settings?.showOnlyIconsOnEditor) {
this.statusLog.value = messageContent; this.statusLog.value = messageContent;
} }
if (this.settings?.writeLogToTheFile) {
this.writeLogToTheFile(now, vaultName, newMessage);
}
addLog(newMessage);
this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage }); this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage });
if (level >= LOG_LEVEL_NOTICE) { if (level >= LOG_LEVEL_NOTICE) {

View File

@@ -35,6 +35,7 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
clampMin: 10, clampMin: 10,
onUpdate: this.onlyOnCouchDB, onUpdate: this.onlyOnCouchDB,
}); });
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
// new Setting(paneEl) // new Setting(paneEl)
// .setClass("wizardHidden") // .setClass("wizardHidden")
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB }) // .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })

View File

@@ -43,10 +43,13 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem
// tmpDiv.addClass("sls-header-button"); // tmpDiv.addClass("sls-header-button");
tmpDiv.addClass("op-warn-info"); tmpDiv.addClass("op-warn-info");
tmpDiv.innerHTML = `<p>${$msg("obsidianLiveSyncSettingTab.msgNewVersionNote")}</p><button>${$msg("obsidianLiveSyncSettingTab.optionOkReadEverything")}</button>`; tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
const readEverythingButton = tmpDiv.createEl("button", {
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
});
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) { if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
const informationButtonDiv = informationDivEl.appendChild(tmpDiv); const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
informationButtonDiv.querySelector("button")?.addEventListener("click", () => { readEverythingButton.addEventListener("click", () => {
fireAndForget(async () => { fireAndForget(async () => {
this.editingSettings.lastReadUpdates = lastVersion; this.editingSettings.lastReadUpdates = lastVersion;
await this.saveAllDirtySettings(); await this.saveAllDirtySettings();

View File

@@ -39,6 +39,7 @@ import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts";
import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts"; import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts"; import type { PageFunctions } from "./SettingPane.ts";
import { generateReport } from "@/common/reportTool.ts";
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); // const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
// hatchWarn.addClass("op-warn-info"); // hatchWarn.addClass("op-warn-info");
@@ -69,140 +70,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE); eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
}) })
); );
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) => new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
button button
.setButtonText($msg("Copy Report to clipboard")) .setButtonText($msg("Copy Report to clipboard"))
.setCta() .setCta()
.setDisabled(false) .setDisabled(false)
.onClick(async () => { .onClick(async () => {
let responseConfig: any = {}; await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
if (this.editingSettings.remoteType == REMOTE_COUCHDB) {
try {
const credential = generateCredentialObject(this.editingSettings);
const customHeaders = parseHeaderValues(this.editingSettings.couchDB_CustomHeaders);
const r = await requestToCouchDBWithCredentials(
this.editingSettings.couchDB_URI,
credential,
window.origin,
undefined,
undefined,
undefined,
customHeaders
);
Logger(JSON.stringify(r.json, null, 2));
responseConfig = r.json;
responseConfig["couch_httpd_auth"].secret = REDACTED;
responseConfig["couch_httpd_auth"].authentication_db = REDACTED;
responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED;
responseConfig["couchdb"].uuid = REDACTED;
responseConfig["admins"] = REDACTED;
delete responseConfig["jwt_keys"];
if ("secret" in responseConfig["chttpd_auth"])
responseConfig["chttpd_auth"].secret = REDACTED;
} catch (ex) {
Logger(ex, LOG_LEVEL_VERBOSE);
responseConfig = {
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
};
}
} else if (this.editingSettings.remoteType == REMOTE_MINIO) {
responseConfig = { error: "Object Storage Synchronisation" };
//
}
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings;
const pluginKeys = Object.keys(pluginConfig);
for (const key of pluginKeys) {
if (defaultKeys.includes(key as any)) continue;
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
}
pluginConfig.couchDB_DBNAME = REDACTED;
pluginConfig.couchDB_PASSWORD = REDACTED;
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
? "(HTTP)"
: pluginConfig.couchDB_URI.startsWith("https:")
? "(HTTPS)"
: "";
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI)
? "cloudant"
: `self-hosted${scheme}`;
pluginConfig.couchDB_USER = REDACTED;
pluginConfig.passphrase = REDACTED;
pluginConfig.encryptedPassphrase = REDACTED;
pluginConfig.encryptedCouchDBConnection = REDACTED;
pluginConfig.accessKey = REDACTED;
pluginConfig.secretKey = REDACTED;
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
const toSchemeOnly = (uri: string) => {
try {
return `${new URL(uri).protocol}//`;
} catch {
const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//);
return matched?.[0] ?? REDACTED;
}
};
pluginConfig.remoteConfigurations = Object.fromEntries(
Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [
id,
{
...config,
uri: toSchemeOnly(config.uri),
},
])
);
pluginConfig.region = redact(pluginConfig.region);
pluginConfig.bucket = redact(pluginConfig.bucket);
pluginConfig.pluginSyncExtendedSetting = {};
pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID);
pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase);
pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID);
pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays);
pluginConfig.jwtKey = redact(pluginConfig.jwtKey);
pluginConfig.jwtSub = redact(pluginConfig.jwtSub);
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";
} else {
const endpointScheme = pluginConfig.endpoint.startsWith("http:")
? "(HTTP)"
: pluginConfig.endpoint.startsWith("https:")
? "(HTTPS)"
: "";
pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`;
}
const obsidianInfo = {
navigator: navigator.userAgent,
fileSystem: this.core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive",
};
const msgConfig = `# ---- Obsidian info ----
${stringifyYaml(obsidianInfo)}
---
# ---- remote config ----
${stringifyYaml(responseConfig)}
---
# ---- Plug-in config ----
${stringifyYaml({
version: this.manifestVersion,
...pluginConfig,
})}`;
console.log(msgConfig);
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) new Setting(paneEl)

View File

@@ -121,13 +121,13 @@ export function paneSetup(
const repo = "vrtmrz/obsidian-livesync"; const repo = "vrtmrz/obsidian-livesync";
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting"); const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
this.createEl( this.createEl(paneEl, "div", "", (el) => {
paneEl, el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
"div", anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
"", anchor.target = "_blank";
(el) => anchor.rel = "noopener";
(el.innerHTML = `<a href='https://github.com/${repo}/blob/main${topPath}' target="_blank">${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}</a>`) });
); });
const troubleShootEl = this.createEl(paneEl, "div", { const troubleShootEl = this.createEl(paneEl, "div", {
text: "", text: "",
cls: "sls-troubleshoot-preview", cls: "sls-troubleshoot-preview",

View File

@@ -13,7 +13,7 @@ export const checkConfig = async (
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO); Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
let isSuccessful = true; let isSuccessful = true;
const emptyDiv = createDiv(); const emptyDiv = createDiv();
emptyDiv.innerHTML = "<span></span>"; emptyDiv.createSpan();
checkResultDiv?.replaceChildren(...[emptyDiv]); checkResultDiv?.replaceChildren(...[emptyDiv]);
const addResult = (msg: string, classes?: string[]) => { const addResult = (msg: string, classes?: string[]) => {
const tmpDiv = createDiv(); const tmpDiv = createDiv();
@@ -21,7 +21,7 @@ export const checkConfig = async (
if (classes) { if (classes) {
tmpDiv.addClasses(classes); tmpDiv.addClasses(classes);
} }
tmpDiv.innerHTML = `${msg}`; tmpDiv.textContent = msg;
checkResultDiv?.appendChild(tmpDiv); checkResultDiv?.appendChild(tmpDiv);
}; };
try { try {
@@ -47,9 +47,10 @@ export const checkConfig = async (
if (!checkResultDiv) return; if (!checkResultDiv) return;
const tmpDiv = createDiv(); const tmpDiv = createDiv();
tmpDiv.addClass("ob-btn-config-fix"); tmpDiv.addClass("ob-btn-config-fix");
tmpDiv.innerHTML = `<label>${title}</label><button>${$msg("obsidianLiveSyncSettingTab.btnFix")}</button>`; tmpDiv.createEl("label", { text: title });
const fixButton = tmpDiv.createEl("button", { text: $msg("obsidianLiveSyncSettingTab.btnFix") });
const x = checkResultDiv.appendChild(tmpDiv); const x = checkResultDiv.appendChild(tmpDiv);
x.querySelector("button")?.addEventListener("click", () => { fixButton.addEventListener("click", () => {
fireAndForget(async () => { fireAndForget(async () => {
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value })); Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
const res = await requestToCouchDBWithCredentials( const res = await requestToCouchDBWithCredentials(

View File

@@ -7,7 +7,7 @@
const TYPE_CLOSE = "close"; const TYPE_CLOSE = "close";
type ResultType = typeof TYPE_CLOSE; type ResultType = typeof TYPE_CLOSE;
type Props = { type Props = {
setResult: (result: ResultType) => void; setResult: (_result: ResultType) => void;
}; };
const { setResult }: Props = $props(); const { setResult }: Props = $props();
</script> </script>

View File

@@ -48,6 +48,8 @@
bind:value={userType} bind:value={userType}
> >
This is an advanced option for users who do not have a URI or who wish to configure detailed settings. This is an advanced option for users who do not have a URI or who wish to configure detailed settings.
You can also select this option if you intend to use <strong>P2P (Peer-to-Peer) synchronisation</strong>
instead of a CouchDB/S3 server — P2P requires no server setup at all.
</Option> </Option>
</Options> </Options>
</Instruction> </Instruction>

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
// import { delay } from "octagonal-wheels/promises"; // import { delay } from "octagonal-wheels/promises";
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte"; import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte"; import Guidance from "@lib/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte"; import Decision from "@lib/UI/components/Decision.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte"; import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte"; import InfoNote from "@lib/UI/components/InfoNote.svelte";
import InputRow from "@/lib/src/UI/components/InputRow.svelte"; import InputRow from "@lib/UI/components/InputRow.svelte";
import Password from "@/lib/src/UI/components/Password.svelte"; import Password from "@lib/UI/components/Password.svelte";
import { PouchDB } from "../../../../lib/src/pouchdb/pouchdb-browser"; import { PouchDB } from "@lib/pouchdb/pouchdb-browser";
import { import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
P2P_DEFAULT_SETTINGS, P2P_DEFAULT_SETTINGS,
@@ -17,15 +17,15 @@
type ObsidianLiveSyncSettings, type ObsidianLiveSyncSettings,
type P2PConnectionInfo, type P2PConnectionInfo,
type P2PSyncSetting, type P2PSyncSetting,
} from "../../../../lib/src/common/types"; } from "@lib/common/types";
import { TrysteroReplicator } from "../../../../lib/src/replication/trystero/TrysteroReplicator"; import { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
import type { ReplicatorHostEnv } from "../../../../lib/src/replication/trystero/types"; import type { ReplicatorHostEnv } from "@lib/replication/trystero/types";
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "../../../../lib/src/common/utils"; import { copyTo, pickP2PSyncSettings, type SimpleStore } from "@lib/common/utils";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog"; import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../../lib/src/common/types"; import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
import ExtraItems from "../../../../lib/src/UI/components/ExtraItems.svelte"; import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS); const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting }); let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
@@ -39,18 +39,20 @@
const { setResult, getInitialData }: Props = $props(); const { setResult, getInitialData }: Props = $props();
onMount(() => { onMount(() => {
let initialData: P2PSyncSetting | undefined = undefined;
if (getInitialData) { if (getInitialData) {
const initialData = getInitialData(); initialData = getInitialData();
if (initialData) { if (initialData) {
copyTo(initialData, syncSetting); 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 = "";
} }
const initialPeerName = (initialData?.P2P_DevicePeerName ?? "").trim();
if (initialPeerName !== "") {
return;
}
const cachedPeerName = context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME);
if (cachedPeerName) {
syncSetting.P2P_DevicePeerName = cachedPeerName as string;
} }
}); });
function generateSetting() { function generateSetting() {
@@ -100,7 +102,7 @@
const dummyPouch = new PouchDB<EntryDoc>("dummy"); const dummyPouch = new PouchDB<EntryDoc>("dummy");
const env: ReplicatorHostEnv = { const env: ReplicatorHostEnv = {
settings: trialRemoteSetting, settings: trialRemoteSetting,
processReplicatedDocs: async (docs: any[]) => { processReplicatedDocs: async (_docs: any[]) => {
return; return;
}, },
confirm: context.services.confirm, confirm: context.services.confirm,
@@ -116,7 +118,7 @@
await replicator.open(); await replicator.open();
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
// await delay(1000); // await delay(1000);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => window.setTimeout(resolve, 1000));
// Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO); // Logger(`Checking known advertisements... (${i})`, LOG_LEVEL_INFO);
if (replicator.knownAdvertisements.length > 0) { if (replicator.knownAdvertisements.length > 0) {
break; break;

View File

@@ -38,6 +38,25 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
} }
} }
override async showWindowOnRight(viewType: string): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(viewType);
if (existing.length > 0) {
await this.app.workspace.revealLeaf(existing[0]);
return;
}
const rightLeaf = this.app.workspace.getRightLeaf(false);
if (rightLeaf) {
await rightLeaf.setViewState({
type: viewType,
active: false,
});
await this.app.workspace.revealLeaf(rightLeaf);
return;
}
await this.showWindow(viewType);
}
private get app() { private get app() {
return this.context.app; return this.context.app;
} }

View File

@@ -3,11 +3,22 @@ import { createServiceFeature } from "@lib/interfaces/ServiceModule";
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta"; import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
import { $msg, setLang } from "@lib/common/i18n"; import { $msg, setLang } from "@lib/common/i18n";
function tryGetLanguage() {
try {
// Note: 1.8.7+ is required. but it is 18, Feb., 2025. we want to fallback on earlier versions, so we catch the error here.
// eslint-disable-next-line obsidianmd/no-unsupported-api
return getLanguage();
} catch (e) {
console.error("Failed to get Obsidian language, defaulting to 'def'", e);
return "en";
}
}
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => { export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => {
let isChanged = false; let isChanged = false;
const settings = setting.currentSettings(); const settings = setting.currentSettings();
if (settings.displayLanguage == "") { if (settings.displayLanguage == "") {
const obsidianLanguage = getLanguage(); const obsidianLanguage = tryGetLanguage();
if ( if (
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting

View File

@@ -5,7 +5,7 @@ import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/re
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte"; import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte"; import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
import { extractObject } from "octagonal-wheels/object"; import { extractObject } from "octagonal-wheels/object";
import { REMOTE_MINIO } from "@lib/common/models/setting.const"; import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition"; import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
@@ -200,6 +200,13 @@ export async function adjustSettingToRemoteIfNeeded(
return; return;
} }
// P2P has no centralised remote configuration; skip to avoid a spurious
// "Failed to connect to the remote server" error dialog.
if (config.remoteType === REMOTE_P2P) {
log("Remote configuration fetch skipped (P2P mode).", LOG_LEVEL_INFO);
return;
}
// Remote configuration fetched and applied. // Remote configuration fetched and applied.
if (await adjustSettingToRemote(host, log, config)) { if (await adjustSettingToRemote(host, log, config)) {
config = host.services.setting.currentSettings(); config = host.services.setting.currentSettings();

View File

@@ -4,7 +4,13 @@ import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult"; import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP2PReplicatorResult";
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector"; import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
import {
P2PServerStatusPaneView,
VIEW_TYPE_P2P_SERVER_STATUS,
} from "@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView";
import type { LiveSyncCore } from "@/main"; import type { LiveSyncCore } from "@/main";
import type { WorkspaceLeaf } from "@/deps";
import { REMOTE_P2P } from "@lib/common/models/setting.const";
/** /**
* ServiceFeature: P2P Replicator lifecycle management. * ServiceFeature: P2P Replicator lifecycle management.
@@ -33,6 +39,19 @@ export function useP2PReplicatorUI(
core: LiveSyncCore, core: LiveSyncCore,
replicator: UseP2PReplicatorResult replicator: UseP2PReplicatorResult
) { ) {
const api = host.services.API as {
showWindow: (type: string) => Promise<void>;
showWindowOnRight?: (type: string) => Promise<void>;
registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void;
addCommand: (command: { id: string; name: string; callback: () => void }) => unknown;
addRibbonIcon: (
icon: string,
title: string,
callback: () => void
) => { addClass?: (name: string) => unknown } | undefined;
getPlatform: () => string;
};
// const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any }; // const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any };
const getReplicator = () => replicator.replicator; const getReplicator = () => replicator.replicator;
const p2pLogCollector = new P2PLogCollector(); const p2pLogCollector = new P2PLogCollector();
@@ -43,33 +62,106 @@ export function useP2PReplicatorUI(
// Register view, commands and ribbon if a view factory is provided // Register view, commands and ribbon if a view factory is provided
const viewType = VIEW_TYPE_P2P; const viewType = VIEW_TYPE_P2P;
const factory = (leaf: any) => { const factory = (leaf: WorkspaceLeaf) => {
return new P2PReplicatorPaneView(leaf, core, { return new P2PReplicatorPaneView(leaf, core, {
replicator: getReplicator(), replicator: getReplicator(),
p2pLogCollector, p2pLogCollector,
storeP2PStatusLine, storeP2PStatusLine,
}); });
}; };
const openPane = () => host.services.API.showWindow(viewType); const statusFactory = (leaf: WorkspaceLeaf) => {
host.services.API.registerWindow(viewType, factory); return new P2PServerStatusPaneView(leaf, core, {
replicator: getReplicator(),
p2pLogCollector,
storeP2PStatusLine,
});
};
const openPane = () => api.showWindow(viewType);
const openStatusPane = () => {
if (api.showWindowOnRight) {
return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
}
return api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
};
api.registerWindow(viewType, factory);
api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory);
host.services.appLifecycle.onInitialise.addHandler(() => { host.services.appLifecycle.onInitialise.addHandler(() => {
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => { eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => {
void openPane(); void openPane();
}); });
host.services.API.addCommand({ api.addCommand({
id: "open-p2p-replicator", id: "open-p2p-replicator",
name: "P2P Sync : Open P2P Replicator", name: "P2P Sync : Open P2P Replicator (Old UI)",
callback: () => { callback: () => {
void openPane(); void openPane();
}, },
}); });
host.services.API.addRibbonIcon("waypoints", "P2P Replicator", () => { api.addCommand({
void openPane(); id: "open-p2p-server-status",
})?.addClass?.("livesync-ribbon-replicate-p2p"); name: "P2P Sync : Open P2P Status",
callback: () => {
void openStatusPane();
},
});
host.services.API.addCommand({
id: "replicate-now-by-p2p-default-peer",
name: "Replicate P2P to default peer",
checkCallback: (isChecking: boolean) => {
const settings = host.services.setting.currentSettings();
if (isChecking) {
if (settings.remoteType == REMOTE_P2P) return false;
return replicator.replicator?.server?.isServing ?? false;
}
void replicator.replicator?.openReplication(settings, false, true, false);
},
});
host.services.API.addCommand({
id: "replicate-now-by-p2p",
name: "Replicate now by P2P",
checkCallback: (isChecking: boolean) => {
const settings = host.services.setting.currentSettings();
if (isChecking) {
if (settings.remoteType == REMOTE_P2P) return false;
return replicator.replicator?.server?.isServing ?? false;
}
void replicator.replicator?.openReplication(settings, false, true, false);
},
});
host.services.API.addCommand({
id: "p2p-sync-targets",
name: "P2P: Sync with targets",
checkCallback: (isChecking: boolean) => {
if (isChecking) {
return replicator.replicator?.server?.isServing ?? false;
}
void replicator.replicator?.replicateFromCommand(true);
},
});
// api.addRibbonIcon("waypoints", "P2P Replicator", () => {
// void openPane();
// })?.addClass?.("livesync-ribbon-replicate-p2p");
api.addRibbonIcon("waypoints", "P2P Status", () => {
void openStatusPane();
})?.addClass?.("livesync-ribbon-p2p-server-status");
return Promise.resolve(true);
});
host.services.appLifecycle.onLayoutReady.addHandler(() => {
if (api.getPlatform() !== "obsidian") {
return Promise.resolve(true);
}
if (api.showWindowOnRight) {
void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS);
} else {
void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS);
}
return Promise.resolve(true); return Promise.resolve(true);
}); });
return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine }; return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine };

View File

@@ -38,10 +38,20 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
} }
async delete(file: TFile | TFolder, force = false): Promise<void> { async delete(file: TFile | TFolder, force = false): Promise<void> {
// if ("trashFile" in this.app.fileManager) {
// // eslint-disable-next-line obsidianmd/no-unsupported-api
// return await this.app.fileManager.trashFile(file);
// }
//TODO: need fix
return await this.app.vault.delete(file, force); return await this.app.vault.delete(file, force);
} }
async trash(file: TFile | TFolder, force = false): Promise<void> { async trash(file: TFile | TFolder, force = false): Promise<void> {
// if ("trashFile" in this.app.fileManager) {
// // eslint-disable-next-line obsidianmd/no-unsupported-api
// return await this.app.fileManager.trashFile(file);
// }
//TODO: need fix
return await this.app.vault.trash(file, force); return await this.app.vault.trash(file, force);
} }

View File

@@ -3,7 +3,98 @@ 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. The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
## Unreleased ## ~~0.25.66~~ 0.25.67
20th May, 2026
0.25.66 had a bug that the auto-accept logic for compatible but lossy mismatches was not working as intended.
### New features
- Implement an auto-accept compatible tweak setting and enhance the mismatch resolution logic.
### Improved
- Many messages related to tweak mismatch resolution have been updated for clarity.
## 0.25.65
19th May, 2026
### Fixed
- Fix an issue about resuming from background on iOS (#888).
- Now Chunk Splitter: `V3: Fine Deduplication` is working fine again (#866).
- It has some drawbacks, such as fewer chunks are generated. However, it makes less transfer and storage when the files are modified but not completely changed.
- Unsynchronised local changes (which means changes that have not been sent) are now correctly preserved as a conflict (Thank you so much for @SeleiXi!).
- Avoid creating a new revision when the current and conflicted revisions have identical content (Thank you so much for @daichi-629).
### Improved
- Improved the error verbosity on concurrent processing during the start-up process.
- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`).
- Updating logs is now debounced to avoid excessive updates during rapid log generation.
- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue.
## 0.25.64
17th May, 2026
### P2P Status Pane
- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue.
- Added per-peer immediate replication action on accepted peers.
- Updated status control icons for clarity:
- Replicate now: `🔄` (`⏳` while running)
- Watch: `🔔` / `🔕`
- Sync target: `🔗` / `⛓️‍💥`
- Added warning state when no active P2P remote is selected.
### P2P Status Card
- Added stable Room ID suffix display and placed it above Peer ID for better identification.
### Non behavioural internal changes
#### P2P
- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote.
- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged.
- Added migration support to carry over P2P active remote selection when appropriate.
- Added shared Room ID utility functions and applied them across P2P setup and P2P panes.
#### Tests
- Added/updated unit test coverage around settings load behaviour for P2P active remote application.
## 0.25.63
17th May, 2026
### Fixed
- The issue which cannot synchronise in Only-P2P mode has been fixed.
- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P.
### P2P Replication UI Improvements
- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network.
- For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md).
- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click.
- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience.
## 0.25.62
14th May, 2026
### Fixed
- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660).
## 0.25.61
13th May, 2026
Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint.
I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away.
From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious.
That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that.
### Improved ### Improved
@@ -14,6 +105,29 @@ This P2P synchronisation is not compatible with previous versions in terms of co
### Fixed ### Fixed
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation. - No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!)
### New features
- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871)
### Translations
- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!)
- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!)
- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!)
### CLI
#### New features
- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843)
- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!)
- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872)
#### Fixed
- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893)
## 0.25.60 ## 0.25.60
@@ -125,170 +239,5 @@ This P2P synchronisation is not compatible with previous versions in terms of co
- Add coverage for the test. - Add coverage for the test.
- Pop-ups are now shown in the web app as well. - Pop-ups are now shown in the web app as well.
## 0.25.53
17th March, 2026
I did wonder whether I should have released a minor version update, but when I actually tested it, compatibility seemed to be intact, so I didnt. Hmm.
### Fixed
#### P2P Synchronisation
- Fixed flaky timing issues in P2P synchronisation.
- No longer unexpected `Unhandled Rejections` during P2P operations (waiting for acceptance).
#### Journal Sync
- Fixed an issue where some conflicts cannot be resolved in Journal Sync.
- Many minor fixes have been made for better stability and reliability.
### Tests
- Rewrite P2P end-to-end tests to use the CLI as a host.
### CLI
We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details.
- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version.
- P2P sync and Object Storage are also supported in the CLI.
- Yes, we have finally managed to 'get one file'.
- Also, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can do it.
- Now binary files are also supported in the CLI.
### Refactored or internal changes
- ServiceFileAccessBase now correctly handles the reading of binary files.
- HeadlessAPIService now correctly provides the online status (always online) to the plug-in.
- Non-worker version of bgWorker now correctly handles some functions.
- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`.
- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`.
- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`.
- Offline change scanner and the local database preparation have been separated.
- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks.
- ControlService now provides the readiness for processing operations.
- DatabaseService is now able to modify database opening options on derived classes.
- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`.
- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported.
- Journal Replicator now yields true after the replication is done.
### R&D
- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing.
- We can see the code in `src/apps/webapp` for the browser version.
## 0.25.52
9th March, 2026
Excuses: Too much `I`.
Whilst I had a fever, I could not figure it out at all, but once I felt better, I spotted the problem in about thirty seconds. I apologise for causing you concern. I am grateful for your patience.
I would like to devise a mechanism for running simple test scenarios. Now that we have got the Obsidian CLI up and running, it seems the perfect opportunity.
To improve the bus factor, we really need to organise the source code more thoroughly. Your cooperation and contributions would be greatly appreciated.
### Fixed
- No longer unexpected deletion-propagation occurs when the parent directory is not empty (#813).
### Revert reversions
- Reverted the reversion of ModuleCheckRemoteSize. Now it is back to the service feature.
## 0.25.51
7th March, 2026
### Reverted
- Reverted to ModuleRedFlag and ModuleInitializerFile to the previous version because of some unexpected issues. (#813)
- I will re-implement them in the future with better design and tests.
## 0.25.50
3rd March, 2026
Note: 0.25.49 has been skipped because of too verbose logging (credentials are logged in verbose level, but I realised that could lead to unexpected exposure on issue reporting). Please bump to 0.25.50 to get the fix if you are on 0.25.49. (No expected behaviour changes except the logging).
### Fixed
- No longer deleted files are not clickable in the Global History pane.
- Diff view now uses more specific classes (#803).
- A message of configuration mismatching slightly added for better understanding.
- Now it says `When replication is initiated manually via the command palette or ribbon, a dialogue box will open to address this.` to make it clear that the user can fix the issue by themselves.
### Refactored
- `ModuleRedFlag` has been refactored to `serviceFeatures/redFlag` and also tested.
- `ModuleInitializerFile` has been refactored to `lib/serviceFeatures/offlineScanner` and also tested.
## 0.25.48
2nd March, 2026
No behavioural changes except unidentified faults. Please report if you find any unexpected behaviour after this update.
### Refactored
- Many storage-related functions have been refactored for better maintainability and testability.
- Now all platform-specific logics are supplied as adapters, and the core logic has become platform-agnostic.
- Quite a number of tests have been added for the core logic, and the platform-specific logics are also tested with mocked adapters.
## 0.25.47
27th February, 2026
Phew, the financial year is still not over yet, but I have got some time to work on the plug-in again!
### Fixed and refactored
- Fixed the inexplicable behaviour when retrieving chunks from the network.
- The chunk manager has been layered to be responsible for its own areas and duties. e.g., `DatabaseWriteLayer`, `DatabaseReadLayer`, `NetworkLayer`, `CacheLayer`, and `ArrivalWaitLayer`.
- All layers have been tested now!
- `LayeredChunkManager` has been implemented to manage these layers. Also tested.
- `EntryManager` has been mostly rewritten and also tested.
- Now we can configure `Never warn` for remote storage size notification again.
### Tests
- The following test has been added:
- `ConflictManager`.
## 0.25.46
26th February, 2026
### Fixed
- Unexpected errors no longer occurred when the plug-in was unloaded.
- Hidden File Sync now respects selectors.
- Registering protocol-handlers now works safely without causing unexpected errors.
### Refactored
- `ModuleCheckRemoteSize` has been ported to a serviceFeature, and tests have also been added.
- Some unnecessary things have been removed.
- LiveSyncManagers has now explicit dependencies.
- LiveSyncLocalDB is now responsible for LiveSyncManagers, not accepting the managers as dependencies.
- This is to avoid circular dependencies and clarify the ownership of the managers.
- ChangeManager has been refactored. This had a potential issue, so something had been fixed, possibly.
- Some tests have been ported from Deno's test runner to Vitest to accumulate coverage.
## 0.25.45
25th February, 2026
As a result of recent refactoring, we are able to write tests more easily now!
### Refactored
- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature.
- And also tests have been added. The middleware-style-power.
- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`.
- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`.
Full notes are in Full notes are in
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).

View File

@@ -4,6 +4,172 @@ 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. 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.64
17th May, 2026
### P2P Status Pane
- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue.
- Added per-peer immediate replication action on accepted peers.
- Updated status control icons for clarity:
- Replicate now: `🔄` (`⏳` while running)
- Watch: `🔔` / `🔕`
- Sync target: `🔗` / `⛓️‍💥`
- Added warning state when no active P2P remote is selected.
### P2P Status Card
- Added stable Room ID suffix display and placed it above Peer ID for better identification.
### Non behavioural internal changes
#### P2P
- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote.
- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged.
- Added migration support to carry over P2P active remote selection when appropriate.
- Added shared Room ID utility functions and applied them across P2P setup and P2P panes.
#### Tests
- Added/updated unit test coverage around settings load behaviour for P2P active remote application.
## 0.25.63
17th May, 2026
### Fixed
- The issue which cannot synchronise in Only-P2P mode has been fixed.
- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P.
### P2P Replication UI Improvements
- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network.
- For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md).
- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click.
- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience.
## 0.25.62
14th May, 2026
### Fixed
- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660).
## 0.25.61
13th May, 2026
Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint.
I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away.
From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious.
That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that.
### Improved
- P2P synchronisation has been made more robust
Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation.
This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated.
### Fixed
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!)
### New features
- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871)
### Translations
- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!)
- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!)
- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!)
### CLI
#### New features
- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843)
- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!)
- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872)
#### Fixed
- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893)
## 0.25.60
29th April, 2026
### Fixed
- Now larger settings can be exported and imported via QR code without issues. (#595)
- When the settings data exceeds the QR code capacity, it is now split into multiple QR codes.
- These QR codes are reassembled by the aggregator page, which collects the split data and reconstructs the original settings.
- Aggregator page is available at `https://vrtmrz.github.io/obsidian-livesync/aggregator.html`, and this file is also included in the repository.
- We will not send the settings data to any server. The QR code data is generated and processed entirely on the client side, ensuring that your settings remain private and secure. HOWEVER, please be careful your network environment.
- Fixed some errors during serialisation and deserialisation of the settings, which caused issues in some cases when importing/exporting settings via QR code.
### Fixed (CLI)
- `ls` and `mirror` commands now provide informative feedback when no documents are found or filters skip all files, resolving the issue where they would exit silently (#860).
- Improved the clarity of CLI command logs by including the total count of processed items.
- The command-line argument `vault` has been renamed to a more appropriate name, `databaseDir`.
- The `mirror` command now accepts a `vault` directory, which specifies the location where the actual files are stored. For compatibility reasons, the previous behaviour is still supported.
## 0.25.59
### Fixed
- No longer Setup-wizard drops username and password silently. (#865)
- Thank you so much for @koteitan !
- Setup URI is now correctly imported (#859).
- Also thank you so much for @koteitan !
### Improved
- now French translation is added by @foXaCe ! Thank you so much!
## 0.25.58
### Fixed
- No longer credentials are broken during object storage configuration (related: #852).
- Fixed a worker-side recursion issue that could raise `Maximum call stack size exceeded` during chunk splitting (related: #855).
- Improved background worker crash cleanup so pending split/encryption tasks are released cleanly instead of being left in a waiting state (related: #855).
- On start-up, the selected remote configuration is now applied to runtime connection fields as well, reducing intermittent authentication failures caused by stale runtime settings (related: #855).
- Issue report generation now redacts `remoteConfigurations` connection strings and keeps only the scheme (e.g. `sls+https://`), so credentials are not exposed in reports.
- Hidden file JSON conflicts no longer keep re-opening and dismissing the merge dialogue before we can act, which fixes persistent unresolvable `data.json` conflicts in plug-in settings sync (related: #850).
## 0.25.57
9th April, 2026
- Packing a batch during the journal sync now continues even if the batch contains no items to upload.
- No unexpected error (about a replicator) during the early stage of initialisation.
- Now error messages are kept hidden if the show status inside the editor is disabled (related: #829).
- Fixed an issue where devices could no longer upload after another device performed 'Fresh Start Wipe' and 'Overwrite remote' in Object Storage mode (#848).
- Each device's local deduplication caches (`knownIDs`, `sentIDs`, `receivedFiles`, `sentFiles`) now track the remote journal epoch (derived from the encryption parameters stored on the remote).
- When the epoch changes, the plugin verifies whether the device's last uploaded file still exists on the remote. If the file is gone, it confirms a remote wipe and automatically clears the stale caches. If the file is still present (e.g. a protocol upgrade without a wipe), the caches are preserved, and only the epoch is updated. This means normal upgrades never cause unnecessary re-processing.
### Translations
- Russian translation has been added! Thank you so much for the contribution, @vipka1n! (#845)
### New features
- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes.
- A user interface for managing multiple remote databases has been added to the settings dialogue. I think no explanation is needed for the UI, but please let me know if you have any questions.
- We can switch between multiple Remote Databases in the settings dialogue.
### CLI
#### Fixed
- Replication progress is now correctly saved and restored in the CLI (related: #846).
## ~~0.25.55~~ 0.25.56 ## ~~0.25.55~~ 0.25.56

View File

@@ -0,0 +1,197 @@
import { glob } from "glob";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promises as fs } from "node:fs";
import { isPlainText, shouldSplitAsPlainText } from "../../src/lib/src/string_and_binary/path";
import { splitPiecesRabinKarp } from "../../src/lib/src/string_and_binary/chunks";
import {
PREFERRED_BASE,
PREFERRED_JOURNAL_SYNC,
PREFERRED_SETTING_CLOUDANT,
PREFERRED_SETTING_SELF_HOSTED,
} from "../../src/lib/src/common/models/setting.const.preferred";
import { type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, MAX_DOC_SIZE_BIN } from "../../src/lib/src/common/types";
async function blobFromString(content: string): Promise<Blob> {
return new Blob([content], { type: "text/plain" });
}
const preferred = PREFERRED_BASE;
const preferredJournal = PREFERRED_JOURNAL_SYNC;
const preferredCouchDB = PREFERRED_SETTING_SELF_HOSTED;
const preferredIBM = PREFERRED_SETTING_CLOUDANT;
function computeChunkSize(overlay: Partial<ObsidianLiveSyncSettings>) {
const settings = { ...DEFAULT_SETTINGS, ...overlay };
const maxChunkSize = Math.floor(MAX_DOC_SIZE_BIN * ((settings.customChunkSize || 0) * 1 + 1));
const pieceSize = maxChunkSize;
const minimumChunkSize = settings.minimumChunkSize;
return { pieceSize, minimumChunkSize };
}
async function testSplit(
splitPiecesRabinKarpFn: typeof splitPiecesRabinKarp,
content: Blob,
settingsOverlay: Partial<ObsidianLiveSyncSettings>
) {
const { pieceSize, minimumChunkSize } = computeChunkSize(settingsOverlay);
const isPlain = content.type === "text/plain";
const chunkGenerator = await splitPiecesRabinKarpFn(content, pieceSize, isPlain, minimumChunkSize);
const chunks = [] as string[];
for await (const chunk of chunkGenerator()) {
chunks.push(chunk);
}
// if there are few chunks, calculate average chunk size except the last chunk which can be smaller due to the way the algorithm works, especially for small files.
const averageChunkSize =
chunks.length > 1
? chunks.slice(0, -1).reduce((acc, chunk) => acc + chunk.length, 0) / (chunks.length - 1)
: chunks.reduce((acc, chunk) => acc + chunk.length, 0) / chunks.length;
const lastChunk = chunks[chunks.length - 1];
// compute minimum chunk size if the last chunk is not the smallest.
const nonLastChunkSizes = chunks.slice(0, -1).map((c) => c.length);
const minChunkSize = nonLastChunkSizes.length > 0 ? Math.min(...nonLastChunkSizes) : lastChunk.length;
const result = {
isPlain,
originalSize: content.size,
chunkCount: chunks.length,
totalLength: chunks.reduce((acc, chunk) => acc + chunk.length, 0),
averageChunkSize: averageChunkSize,
maxChunkSize: Math.max(...chunks.map((c) => c.length)),
minChunkSize: minChunkSize,
uniqueChunks: new Set(chunks).size,
chunks: chunks,
};
return result;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = resolve(__filename, "..");
async function loadFileAsBlob(filePath: string): Promise<Blob> {
if (shouldSplitAsPlainText(filePath)) {
const content = await fs.readFile(filePath, "utf-8");
return blobFromString(content);
} else {
const buffer = await fs.readFile(filePath);
return new Blob([buffer]);
}
}
const testProfiles = [
{ name: "CouchDB", settings: preferredCouchDB },
{ name: "IBM Cloudant", settings: preferredIBM },
{ name: "Journal Sync", settings: preferredJournal },
// { name: "Base", settings: preferred },
];
function modifyBlob(blob: Blob, position: number, insertText: string): Blob {
const before = blob.slice(0, position);
const after = blob.slice(position);
const insert = new Blob([insertText], { type: blob.type });
return new Blob([before, insert, after], { type: blob.type });
}
async function main() {
const results = [] as string[][];
console.log("directory:", __dirname);
const findPath = resolve(__dirname, "../../");
console.warn("CWD:", findPath);
let testFiles = await glob("**/*.*", {
cwd: findPath,
maxDepth: 20,
ignore: ["**/node_modules/**", "**/.obsidian/**", "**/dist/**", "**/build/**", "**/out/**"],
});
testFiles = testFiles.filter((file) => {
const ext = file.split(".").pop()?.toLowerCase() || "";
return ["md", "txt", "json", "csv", "png"].includes(ext);
});
const header = [
"Profile",
"Implementation",
"Edition",
"File",
"Mode",
"Original Size (bytes)",
"Chunk Count",
"Average Chunk Size",
"Max Chunk Size",
"Min Chunk Size",
"Unique Chunks",
"Shared Chunks",
"Savings",
"Newly added (count)",
"Newly consumed (bytes)",
];
for (const profile of testProfiles) {
console.log(`Testing profile: ${profile.name}`);
for (const fn of [splitPiecesRabinKarp]) {
const funcProfile = fn !== splitPiecesRabinKarp ? "Old" : "New";
console.log(`Testing function: ${funcProfile}`);
for (const file of testFiles) {
const filePath = resolve(findPath, file);
const isPlain = shouldSplitAsPlainText(filePath);
const content = await loadFileAsBlob(filePath);
console.log(`Testing file: ${file} (size: ${content.size} bytes)`);
const result = await testSplit(fn, content, profile.settings);
const chunkSizes = result.chunks.map((c) => c.length);
const savings = result.originalSize - chunkSizes.reduce((acc, size) => acc + size, 0);
// console.log(`Result for ${file}:`, result);
results.push([
`${profile.name}`,
funcProfile,
"original",
file,
isPlain ? "plain" : "binary",
content.size.toString(),
result.chunkCount.toString(),
result.averageChunkSize.toFixed(2),
result.maxChunkSize.toString(),
result.minChunkSize.toString(),
result.uniqueChunks.toString(),
"",
savings.toString(),
"",
"",
]);
// add editions (inserting "*") to content on head, 5%, middle, 95%, tail to see if it affects the chunking
const editions = [
{ name: "head", content: modifyBlob(content, 0, "*") },
{ name: "5%", content: modifyBlob(content, Math.floor(content.size * 0.05), "*") },
{ name: "middle", content: modifyBlob(content, Math.floor(content.size * 0.5), "*") },
{ name: "95%", content: modifyBlob(content, Math.floor(content.size * 0.95), "*") },
{ name: "tail", content: modifyBlob(content, content.size, "*") },
];
const baseChunks = result.chunks;
for (const edition of editions) {
console.log(`Testing edition: ${edition.name}`);
const editionResult = await testSplit(fn, edition.content, profile.settings);
const sharedChunks = editionResult.chunks.filter((chunk) => baseChunks.includes(chunk)).length;
const newChunks = editionResult.chunks.filter((chunk) => !baseChunks.includes(chunk));
const editionResultChunkLength = editionResult.chunks.map((c) => c.length);
// console.log(`Result for edition ${edition.name} of ${file}:`, editionResult);
const editionSavings =
editionResult.originalSize - editionResultChunkLength.reduce((acc, size) => acc + size, 0);
// newly added chunks size :
const newChunksSize = newChunks.reduce((acc, chunk) => acc + chunk.length, 0);
results.push([
`${profile.name}`,
funcProfile,
`${edition.name}`,
file,
isPlain ? "plain" : "binary",
edition.content.size.toString(),
editionResult.chunkCount.toString(),
editionResult.averageChunkSize.toFixed(2),
editionResult.maxChunkSize.toString(),
editionResult.minChunkSize.toString(),
editionResult.uniqueChunks.toString(),
sharedChunks.toString(),
editionSavings.toString(),
newChunks.length.toString(),
newChunksSize.toString(),
]);
}
}
}
}
results.unshift(header);
await fs.writeFile(resolve(__dirname, "splitResults.csv"), results.map((r) => r.join(",")).join("\n"));
}
main();

17
version-bump.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, 4));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!Object.values(versions).includes(minAppVersion)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, 4));
}

View File

@@ -1,4 +1,6 @@
{ {
"0.25.61": "1.7.2",
"0.25.60": "1.7.2",
"1.0.1": "0.9.12", "1.0.1": "0.9.12",
"1.0.0": "0.9.7" "1.0.0": "0.9.7"
} }

View File

@@ -2,7 +2,8 @@ import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright"; import { playwright } from "@vitest/browser-playwright";
import viteConfig from "./vitest.config.common"; import viteConfig from "./vitest.config.common";
import path from "path"; import path from "path";
import dotenv from "dotenv"; import { existsSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands"; import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
// P2P test environment variables // P2P test environment variables
@@ -22,8 +23,9 @@ import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./
// General test options (also read from env): // General test options (also read from env):
// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests // ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests
// ENABLE_UI - Set to "true" to open a visible browser window during tests // ENABLE_UI - Set to "true" to open a visible browser window during tests
const defEnv = dotenv.config({ path: ".env" }).parsed; const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
const testEnv = dotenv.config({ path: ".test.env" }).parsed; const defEnv = loadEnvFile(".env");
const testEnv = loadEnvFile(".test.env");
// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence) // Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence)
const p2pEnv: Record<string, string> = {}; const p2pEnv: Record<string, string> = {};
if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID; if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID;

View File

@@ -2,10 +2,13 @@ import { defineConfig, mergeConfig } from "vitest/config";
import { playwright } from "@vitest/browser-playwright"; import { playwright } from "@vitest/browser-playwright";
import viteConfig from "./vitest.config.common"; import viteConfig from "./vitest.config.common";
import path from "path"; import path from "path";
import dotenv from "dotenv"; import { existsSync, readFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands"; import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands";
const defEnv = dotenv.config({ path: ".env" }).parsed;
const testEnv = dotenv.config({ path: ".test.env" }).parsed; const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
const defEnv = loadEnvFile(".env");
const testEnv = loadEnvFile(".test.env");
const env = Object.assign({}, defEnv, testEnv); const env = Object.assign({}, defEnv, testEnv);
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
const enableUI = env?.ENABLE_UI === "true"; const enableUI = env?.ENABLE_UI === "true";