mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-17 21:11:17 +00:00
Compare commits
77 Commits
feat-userh
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
273e7a2b63 | ||
|
|
2572c54744 | ||
|
|
6a7c987985 | ||
|
|
6ef866a77c | ||
|
|
eea26dee74 | ||
|
|
ee24fe8c24 | ||
|
|
9d9364af36 | ||
|
|
83228e2077 | ||
|
|
a379b5bd78 | ||
|
|
4ed1749652 | ||
|
|
9a90256a8a | ||
|
|
f0628a0d2c | ||
|
|
d5e2f57781 | ||
|
|
91c9746886 | ||
|
|
75b44b1636 | ||
|
|
f1fe48c1ee | ||
|
|
437e7c0d9c | ||
|
|
5ffa7ec7ee | ||
|
|
a1859f5d2e | ||
|
|
785af8cb8f | ||
|
|
06e1f4aa4a | ||
|
|
767f22ce9c | ||
|
|
6a9bba702c | ||
|
|
de2397dc3f | ||
|
|
daaad9212e | ||
|
|
a6891374a1 | ||
|
|
b1cadf0549 | ||
|
|
95f40cc954 | ||
|
|
8deaf123d6 | ||
|
|
053813bffb | ||
|
|
cc7af03618 | ||
|
|
a130e3700e | ||
|
|
0549e901b2 | ||
|
|
e9afe06968 | ||
|
|
c45aca4794 | ||
|
|
e2c54aaf43 | ||
|
|
37715d4c9f | ||
|
|
106367fa41 | ||
|
|
538130aa91 | ||
|
|
c9d0357fec | ||
|
|
d05c76da36 | ||
|
|
d2eb6ecbaf | ||
|
|
25a6fde212 | ||
|
|
e8f8b680ef | ||
|
|
6c30f2b863 | ||
|
|
8dda24a689 | ||
|
|
fbbb63906a | ||
|
|
1e66a7f144 | ||
|
|
df79d81475 | ||
|
|
ad71355859 | ||
|
|
95dc079fad | ||
|
|
770d4af4a0 | ||
|
|
3b311248cb | ||
|
|
67996f6d0a | ||
|
|
5772811a45 | ||
|
|
55529cd71e | ||
|
|
2e9b8b7b62 | ||
|
|
4ab2e41d18 | ||
|
|
c0ad8ee15a | ||
|
|
e6ae516493 | ||
|
|
a4d5ef4620 | ||
|
|
3f7bb047ac | ||
|
|
b6b153c0de | ||
|
|
eca6a6e0ba | ||
|
|
ca43d96c46 | ||
|
|
112e3c8b1d | ||
|
|
d1eb105801 | ||
|
|
d5b93e89cd | ||
|
|
e96fe7cde1 | ||
|
|
68e0610f1d | ||
|
|
a6be20695a | ||
|
|
772b6ecf26 | ||
|
|
81dc7f604b | ||
|
|
a9c87fa52e | ||
|
|
e81f023943 | ||
|
|
7a4b76a550 | ||
|
|
f9294446ba |
2
.github/ISSUE_TEMPLATE/issue-report.md
vendored
2
.github/ISSUE_TEMPLATE/issue-report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Issue report
|
name: Issue report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: 'bug'
|
labels: 'uncategorised'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
41
.github/workflows/cli-deno-tests.yml
vendored
41
.github/workflows/cli-deno-tests.yml
vendored
@@ -17,9 +17,48 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
task_matrix: ${{ steps.select.outputs.task_matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Select task matrix
|
||||||
|
id: select
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}"
|
||||||
|
echo "[INFO] Selected task set: $SELECTED_TASK"
|
||||||
|
|
||||||
|
case "$SELECTED_TASK" in
|
||||||
|
test)
|
||||||
|
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:push-pull","test:sync-two-local","test:sync-locked-remote","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-couchdb","test:e2e-matrix"]'
|
||||||
|
;;
|
||||||
|
test:local)
|
||||||
|
TASK_MATRIX='["test:setup-put-cat","test:mirror"]'
|
||||||
|
;;
|
||||||
|
test:e2e-matrix)
|
||||||
|
TASK_MATRIX='["test:e2e-matrix"]'
|
||||||
|
;;
|
||||||
|
test:p2p-sync)
|
||||||
|
TASK_MATRIX='["test:p2p-sync"]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] Unknown task set: $SELECTED_TASK" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "task_matrix=$TASK_MATRIX" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
task: ${{ fromJson(needs.prepare.outputs.task_matrix) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -64,7 +103,7 @@ jobs:
|
|||||||
LIVESYNC_DOCKER_MODE: native
|
LIVESYNC_DOCKER_MODE: native
|
||||||
LIVESYNC_CLI_RETRY: 3
|
LIVESYNC_CLI_RETRY: 3
|
||||||
run: |
|
run: |
|
||||||
TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test' }}"
|
TASK="${{ matrix.task }}"
|
||||||
echo "[INFO] Running Deno task: $TASK"
|
echo "[INFO] Running Deno task: $TASK"
|
||||||
deno task "$TASK"
|
deno task "$TASK"
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const prettierConfig = {
|
|||||||
tabWidth: 4,
|
tabWidth: 4,
|
||||||
printWidth: 120,
|
printWidth: 120,
|
||||||
semi: true,
|
semi: true,
|
||||||
endOfLine: "cr",
|
endOfLine: "lf",
|
||||||
...localPrettierConfig,
|
...localPrettierConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
3
devs.md
3
devs.md
@@ -63,6 +63,9 @@ npm test # Run vitest tests (requires Docker services)
|
|||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
|
|
||||||
|
- Clone with submodules: `git clone --recurse-submodules <repository-url>`
|
||||||
|
- If you already cloned without them, run: `git submodule update --init --recursive`
|
||||||
|
- The shared common library is provided by the `src/lib` submodule, and builds will fail if it is missing
|
||||||
- Create `.env` file with `PATHS_TEST_INSTALL` pointing to test vault plug-in directories (`:` separated on Unix, `;` on Windows)
|
- Create `.env` file with `PATHS_TEST_INSTALL` pointing to test vault plug-in directories (`:` separated on Unix, `;` on Windows)
|
||||||
- Development builds auto-copy to these paths on build
|
- Development builds auto-copy to these paths on build
|
||||||
|
|
||||||
|
|||||||
59
docs/p2p_sync_updates_2026.md
Normal file
59
docs/p2p_sync_updates_2026.md
Normal 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.
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -38,65 +26,58 @@ export default [
|
|||||||
"modules/octagonal-wheels/rollup.config.js",
|
"modules/octagonal-wheels/rollup.config.js",
|
||||||
"modules/octagonal-wheels/dist/**/*",
|
"modules/octagonal-wheels/dist/**/*",
|
||||||
"src/lib/test",
|
"src/lib/test",
|
||||||
|
"src/lib/_tools",
|
||||||
"src/lib/src/cli",
|
"src/lib/src/cli",
|
||||||
"**/main.js",
|
"**/main.js",
|
||||||
"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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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.64",
|
||||||
"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",
|
||||||
|
|||||||
1392
package-lock.json
generated
1392
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.25.60",
|
"version": "0.25.64",
|
||||||
"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,21 +54,21 @@
|
|||||||
"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",
|
||||||
"@types/deno": "^2.5.0",
|
"@types/deno": "^2.5.0",
|
||||||
"@types/diff-match-patch": "^1.0.36",
|
"@types/diff-match-patch": "^1.0.36",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/micromatch": "^4.0.10",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.13",
|
||||||
"@types/pouchdb": "^6.4.2",
|
"@types/pouchdb": "^6.4.2",
|
||||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||||
@@ -83,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",
|
||||||
@@ -115,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",
|
||||||
@@ -133,11 +131,14 @@
|
|||||||
"@smithy/protocol-http": "^5.3.9",
|
"@smithy/protocol-http": "^5.3.9",
|
||||||
"@smithy/querystring-builder": "^4.2.9",
|
"@smithy/querystring-builder": "^4.2.9",
|
||||||
"@trystero-p2p/nostr": "^0.23.0",
|
"@trystero-p2p/nostr": "^0.23.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",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
|
"micromatch": "^4.0.0",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"octagonal-wheels": "^0.1.45",
|
"octagonal-wheels": "^0.1.45",
|
||||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/apps/cli/.gitignore
vendored
2
src/apps/cli/.gitignore
vendored
@@ -4,3 +4,5 @@ test/*
|
|||||||
test/test-init.local.sh
|
test/test-init.local.sh
|
||||||
node_modules
|
node_modules
|
||||||
.*.json
|
.*.json
|
||||||
|
*.env
|
||||||
|
!.test.env
|
||||||
@@ -95,13 +95,24 @@ livesync-cli ./my-db pull folder/note.md ./note.md
|
|||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
|
# Clone with submodules, because the shared core lives in src/lib
|
||||||
# due to shared dependencies with webapp and main library
|
git clone --recurse-submodules <repository-url>
|
||||||
|
cd obsidian-livesync
|
||||||
|
|
||||||
|
# If you already cloned without submodules, run this once instead
|
||||||
|
git submodule update --init --recursive
|
||||||
|
|
||||||
|
# Install dependencies from the repository root
|
||||||
npm install
|
npm install
|
||||||
# Build the project (ensure you are in `src/apps/cli` directory)
|
|
||||||
|
# Build the CLI from its package directory
|
||||||
|
cd src/apps/cli
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `src/lib` is missing, `npm run build` now stops early with a targeted message
|
||||||
|
instead of a low-level Vite `ENOENT` error.
|
||||||
|
|
||||||
Run the CLI:
|
Run the CLI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -286,9 +297,11 @@ Options:
|
|||||||
--force, -f Overwrite existing file on init-settings
|
--force, -f Overwrite existing file on init-settings
|
||||||
--verbose, -v Enable verbose logging
|
--verbose, -v Enable verbose logging
|
||||||
--debug, -d Enable debug logging (includes verbose)
|
--debug, -d Enable debug logging (includes verbose)
|
||||||
--help, -h Show help message
|
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||||
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
||||||
sync Run one replication cycle and exit
|
sync Run one replication cycle and exit
|
||||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||||
@@ -395,6 +408,86 @@ In other words, it performs the following actions:
|
|||||||
|
|
||||||
Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases).
|
Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases).
|
||||||
|
|
||||||
|
##### daemon
|
||||||
|
|
||||||
|
`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions:
|
||||||
|
|
||||||
|
- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`).
|
||||||
|
- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB.
|
||||||
|
|
||||||
|
In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage.
|
||||||
|
|
||||||
|
The daemon exits cleanly on `SIGINT` or `SIGTERM`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LiveSync mode (default — _changes feed, near-real-time)
|
||||||
|
livesync-cli /path/to/vault
|
||||||
|
|
||||||
|
# Polling mode — poll every 60 seconds
|
||||||
|
livesync-cli /path/to/vault --interval 60
|
||||||
|
```
|
||||||
|
|
||||||
|
### .livesync/ignore
|
||||||
|
|
||||||
|
Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local).
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
|
||||||
|
- Lines beginning with `#` are comments.
|
||||||
|
- Blank lines are ignored.
|
||||||
|
- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root.
|
||||||
|
- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules.
|
||||||
|
- Negation patterns (lines starting with `!`) are not supported and will cause an error on load.
|
||||||
|
|
||||||
|
**Example `.livesync/ignore`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# Ignore temporary files
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Ignore build output
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Merge patterns from .gitignore
|
||||||
|
import: .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync.
|
||||||
|
|
||||||
|
Changes to this file require a daemon restart to take effect.
|
||||||
|
|
||||||
|
### Systemd Installation
|
||||||
|
|
||||||
|
The `deploy/` directory contains a systemd unit template and an install script.
|
||||||
|
|
||||||
|
**Automated install (user service, recommended):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
**With polling interval:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
|
||||||
|
```
|
||||||
|
|
||||||
|
**System-wide install** (requires root / sudo for `/etc/systemd/system/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Builds the CLI (`npm install` + `npm run build`).
|
||||||
|
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
|
||||||
|
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
|
||||||
|
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
|
||||||
|
|
||||||
|
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
|
||||||
|
|
||||||
### Planned options:
|
### Planned options:
|
||||||
|
|
||||||
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
|
|
||||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||||
const pathStr = this.normalisePath(p);
|
const pathStr = this.normalisePath(p);
|
||||||
|
|
||||||
const cached = this.fileCache.get(pathStr);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.refreshFile(pathStr);
|
return await this.refreshFile(pathStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,14 +98,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
path: pathStr as FilePath,
|
path: pathStr as FilePath,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.fileCache.set(pathStr, file);
|
this.fileCache.set(pathStr, file);
|
||||||
return file;
|
return file;
|
||||||
} catch {
|
} catch {
|
||||||
|
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||||
this.fileCache.delete(pathStr);
|
this.fileCache.delete(pathStr);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -137,8 +132,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
path: entryRelativePath as FilePath,
|
path: entryRelativePath as FilePath,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
const stat = await fs.stat(this.resolvePath(p));
|
const stat = await fs.stat(this.resolvePath(p));
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: stat.isDirectory() ? "folder" : "file",
|
type: stat.isDirectory() ? "folder" : "file",
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(file: NodeFile): Promise<string> {
|
async read(file: NodeFile): Promise<string> {
|
||||||
return await fs.readFile(this.resolvePath(file.path), "utf-8");
|
const content = await fs.readFile(this.resolvePath(file.path), "utf-8");
|
||||||
|
// Correct stale stat.size — chokidar stats may be from a poll before the final write.
|
||||||
|
// The downstream document integrity check compares stat.size to content length, so
|
||||||
|
// they must agree or other clients reject the file as corrupted.
|
||||||
|
file.stat.size = Buffer.byteLength(content, "utf-8");
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cachedRead(file: NodeFile): Promise<string> {
|
async cachedRead(file: NodeFile): Promise<string> {
|
||||||
@@ -25,6 +30,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
|
|
||||||
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
|
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
|
||||||
const buffer = await fs.readFile(this.resolvePath(file.path));
|
const buffer = await fs.readFile(this.resolvePath(file.path));
|
||||||
|
// Same correction as read() — ensure stat.size matches actual byte length.
|
||||||
|
file.stat.size = buffer.length;
|
||||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +73,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
path: p as any,
|
path: p as any,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -89,8 +96,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
|||||||
path: p as any,
|
path: p as any,
|
||||||
stat: {
|
stat: {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: stat.mtimeMs,
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
ctime: stat.ctimeMs,
|
ctime: Math.floor(stat.ctimeMs),
|
||||||
type: "file",
|
type: "file",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
312
src/apps/cli/commands/daemonCommand.unit.spec.ts
Normal file
312
src/apps/cli/commands/daemonCommand.unit.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { runCommand } from "./runCommand";
|
||||||
|
import type { CLIOptions } from "./types";
|
||||||
|
|
||||||
|
// Mock performFullScan so daemon tests don't require a real CouchDB connection.
|
||||||
|
vi.mock("@lib/serviceFeatures/offlineScanner", () => ({
|
||||||
|
performFullScan: vi.fn(async () => true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock UnresolvedErrorManager to avoid event-hub side effects.
|
||||||
|
vi.mock("@lib/services/base/UnresolvedErrorManager", () => ({
|
||||||
|
UnresolvedErrorManager: class UnresolvedErrorManager {
|
||||||
|
showError() {}
|
||||||
|
clearError() {}
|
||||||
|
clearErrors() {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as offlineScanner from "@lib/serviceFeatures/offlineScanner";
|
||||||
|
|
||||||
|
function createCoreMock() {
|
||||||
|
return {
|
||||||
|
services: {
|
||||||
|
control: {
|
||||||
|
activated: Promise.resolve(),
|
||||||
|
applySettings: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
setting: {
|
||||||
|
applyPartial: vi.fn(async () => {}),
|
||||||
|
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||||
|
},
|
||||||
|
replication: {
|
||||||
|
replicate: vi.fn(async () => true),
|
||||||
|
},
|
||||||
|
appLifecycle: {
|
||||||
|
onUnload: {
|
||||||
|
addHandler: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceModules: {
|
||||||
|
fileHandler: {
|
||||||
|
dbToStorage: vi.fn(async () => true),
|
||||||
|
storeFileToDB: vi.fn(async () => true),
|
||||||
|
},
|
||||||
|
storageAccess: {
|
||||||
|
readFileAuto: vi.fn(async () => ""),
|
||||||
|
writeFileAuto: vi.fn(async () => {}),
|
||||||
|
},
|
||||||
|
databaseFileAccess: {
|
||||||
|
fetch: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDaemonOptions(interval?: number): CLIOptions {
|
||||||
|
return {
|
||||||
|
command: "daemon",
|
||||||
|
commandArgs: [],
|
||||||
|
databasePath: "/tmp/vault",
|
||||||
|
verbose: false,
|
||||||
|
force: false,
|
||||||
|
interval,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseContext = {
|
||||||
|
vaultPath: "/tmp/vault",
|
||||||
|
settingsPath: "/tmp/vault/.livesync/settings.json",
|
||||||
|
originalSyncSettings: {
|
||||||
|
liveSync: true,
|
||||||
|
syncOnStart: false,
|
||||||
|
periodicReplication: false,
|
||||||
|
syncOnSave: false,
|
||||||
|
syncOnEditorSave: false,
|
||||||
|
syncOnFileOpen: false,
|
||||||
|
syncAfterMerge: false,
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
describe("daemon command", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls performFullScan during startup", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when performFullScan fails", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling mode: calls setTimeout when interval option is set", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
// Interval should be in milliseconds (30s → 30000ms)
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ suspendFileWatching: false }),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveSync mode: calls applyPartial and applySettings", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
...baseContext.originalSyncSettings,
|
||||||
|
suspendFileWatching: false,
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveSync mode: logs warning when both liveSync and syncOnStart are false", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.setting.currentSettings = vi.fn(() => ({
|
||||||
|
liveSync: false,
|
||||||
|
syncOnStart: false,
|
||||||
|
}));
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
const warningCalls = consoleSpy.mock.calls.filter(
|
||||||
|
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||||
|
);
|
||||||
|
expect(warningCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveSync mode: no warning when liveSync is true", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.setting.currentSettings = vi.fn(() => ({
|
||||||
|
liveSync: true,
|
||||||
|
syncOnStart: false,
|
||||||
|
}));
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
const warningCalls = consoleSpy.mock.calls.filter(
|
||||||
|
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||||
|
);
|
||||||
|
expect(warningCalls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls replicate before performFullScan", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
core.services.replication.replicate = vi.fn(async () => {
|
||||||
|
callOrder.push("replicate");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||||
|
callOrder.push("performFullScan");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when initial replication fails", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.replication.replicate = vi.fn(async () => false);
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||||
|
|
||||||
|
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
// performFullScan should NOT have been called
|
||||||
|
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||||
|
|
||||||
|
// onUnload handler should have been registered
|
||||||
|
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||||
|
const handler = core.services.appLifecycle.onUnload.addHandler.mock.calls[0][0];
|
||||||
|
|
||||||
|
// Get the timeout ID that was created
|
||||||
|
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||||
|
await handler();
|
||||||
|
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||||
|
let callCount = 0;
|
||||||
|
core.services.replication.replicate = vi.fn(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return true; // initial startup replicate
|
||||||
|
if (callCount <= 7) throw new Error("network failure");
|
||||||
|
return true; // recovery
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseMs = 30 * 1000;
|
||||||
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||||
|
|
||||||
|
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||||
|
|
||||||
|
// After runCommand returns the first setTimeout has been scheduled.
|
||||||
|
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||||
|
expect(setTimeoutSpy.mock.calls[0][1]).toBe(baseMs);
|
||||||
|
|
||||||
|
// Advance through 6 failure polls. After each failure the next setTimeout
|
||||||
|
// should be scheduled with a larger (or capped) interval.
|
||||||
|
// formula: min(base * 2^n, 300000). base=30000ms.
|
||||||
|
// failure 1: 30000*2=60000, failure 2: 30000*4=120000,
|
||||||
|
// failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap
|
||||||
|
const expectedIntervals = [
|
||||||
|
baseMs * 2, // after failure 1: 60000
|
||||||
|
baseMs * 4, // after failure 2: 120000
|
||||||
|
baseMs * 8, // after failure 3: 240000
|
||||||
|
300_000, // after failure 4 (would be 480000, capped)
|
||||||
|
300_000, // after failure 5 (cap)
|
||||||
|
300_000, // after failure 6 (cap)
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const expected of expectedIntervals) {
|
||||||
|
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||||
|
const newCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
expect(newCallCount).toBeGreaterThan(prevCallCount);
|
||||||
|
expect(setTimeoutSpy.mock.calls[newCallCount - 1][1]).toBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now trigger the success poll — interval should halve each time toward base.
|
||||||
|
// After failure 6, consecutiveFailures=6, currentIntervalMs=300000.
|
||||||
|
// On success: consecutiveFailures=5, currentIntervalMs=150000.
|
||||||
|
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||||
|
const afterSuccessCallCount = setTimeoutSpy.mock.calls.length;
|
||||||
|
expect(afterSuccessCallCount).toBeGreaterThan(prevCallCount);
|
||||||
|
// The interval after one success should be halved (300000 / 2 = 150000).
|
||||||
|
expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polling error handling: replicate rejection is caught and console.error is called", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||||
|
let callCount = 0;
|
||||||
|
core.services.replication.replicate = vi.fn(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return true; // startup replicate
|
||||||
|
throw new Error("network failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
const intervalMs = 30 * 1000;
|
||||||
|
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||||
|
|
||||||
|
// Advance time to trigger the first poll callback and flush its async work.
|
||||||
|
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||||
|
|
||||||
|
// No unhandled rejection — the error was caught internally.
|
||||||
|
const errorCalls = consoleSpy.mock.calls.filter(
|
||||||
|
(args) => typeof args[0] === "string" && args[0].includes("Poll error")
|
||||||
|
);
|
||||||
|
expect(errorCalls.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,106 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
|
|
||||||
await core.services.control.activated;
|
await core.services.control.activated;
|
||||||
if (options.command === "daemon") {
|
if (options.command === "daemon") {
|
||||||
|
const log = (msg: unknown) => console.error(`[Daemon] ${msg}`);
|
||||||
|
|
||||||
|
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||||
|
// and the default "Dismiss" action would block replication. The daemon should
|
||||||
|
// accept whatever configuration the remote has.
|
||||||
|
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||||
|
|
||||||
|
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||||
|
log("Replicating from CouchDB...");
|
||||||
|
const replResult = await core.services.replication.replicate(true);
|
||||||
|
if (!replResult) {
|
||||||
|
console.error("[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log("CouchDB replication complete");
|
||||||
|
|
||||||
|
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||||
|
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle);
|
||||||
|
log("Running mirror scan...");
|
||||||
|
const scanOk = await performFullScan(core as any, log, errorManager, false, true);
|
||||||
|
if (!scanOk) {
|
||||||
|
console.error("[Daemon] Mirror scan failed, cannot continue");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
log("Mirror scan complete");
|
||||||
|
|
||||||
|
// 3. Re-enable sync.
|
||||||
|
const restoreSyncSettings = async () => {
|
||||||
|
await core.services.setting.applyPartial(
|
||||||
|
{
|
||||||
|
...context.originalSyncSettings,
|
||||||
|
suspendFileWatching: false,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||||
|
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||||
|
// via fireAndForget.
|
||||||
|
await core.services.control.applySettings();
|
||||||
|
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||||
|
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||||
|
// with true is a direct store write — it does not re-trigger lifecycle.
|
||||||
|
await core.services.setting.applyPartial(
|
||||||
|
{
|
||||||
|
suspendFileWatching: false,
|
||||||
|
suspendParseReplicationResult: false,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (options.interval) {
|
||||||
|
log(`Polling mode: syncing every ${options.interval}s`);
|
||||||
|
await restoreSyncSettings();
|
||||||
|
const baseIntervalMs = options.interval * 1000;
|
||||||
|
let currentIntervalMs = baseIntervalMs;
|
||||||
|
let consecutiveFailures = 0;
|
||||||
|
const maxIntervalMs = 5 * 60 * 1000; // 5 minutes cap
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
await core.services.replication.replicate(true);
|
||||||
|
if (consecutiveFailures > 0) {
|
||||||
|
consecutiveFailures--;
|
||||||
|
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||||
|
log(`Replication recovered`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
consecutiveFailures++;
|
||||||
|
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
||||||
|
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
|
||||||
|
if (consecutiveFailures >= 5) {
|
||||||
|
console.error(
|
||||||
|
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pollTimer = setTimeout(poll, currentIntervalMs);
|
||||||
|
};
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> = setTimeout(poll, currentIntervalMs);
|
||||||
|
core.services.appLifecycle.onUnload.addHandler(async () => {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||||
|
await restoreSyncSettings();
|
||||||
|
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||||
|
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||||
|
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||||
|
log("LiveSync active");
|
||||||
|
const currentSettings = core.services.setting.currentSettings();
|
||||||
|
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||||
|
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, " +
|
||||||
|
"or use --interval for polling mode."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +183,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
||||||
|
|
||||||
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
||||||
mtime: sourceStat.mtimeMs,
|
mtime: Math.floor(sourceStat.mtimeMs),
|
||||||
ctime: sourceStat.ctimeMs,
|
ctime: Math.floor(sourceStat.ctimeMs),
|
||||||
});
|
});
|
||||||
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
||||||
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||||
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||||
|
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||||
|
|
||||||
export type CLICommand =
|
export type CLICommand =
|
||||||
| "daemon"
|
| "daemon"
|
||||||
@@ -29,15 +30,27 @@ export interface CLIOptions {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
command: CLICommand;
|
command: CLICommand;
|
||||||
commandArgs: string[];
|
commandArgs: string[];
|
||||||
|
interval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CLICommandContext {
|
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"
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VALID_COMMANDS = new Set([
|
export const VALID_COMMANDS = new Set([
|
||||||
|
"daemon",
|
||||||
"sync",
|
"sync",
|
||||||
"p2p-peers",
|
"p2p-peers",
|
||||||
"p2p-sync",
|
"p2p-sync",
|
||||||
|
|||||||
187
src/apps/cli/deploy/install.sh
Executable file
187
src/apps/cli/deploy/install.sh
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — install livesync-cli as a systemd service
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# install.sh [--user] [--system] [--vault <path>] [--interval <N>]
|
||||||
|
#
|
||||||
|
# Defaults: user install, prompts for vault path if not supplied.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||||
|
CLI_DIR="$REPO_ROOT/src/apps/cli"
|
||||||
|
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
|
||||||
|
|
||||||
|
# ── Argument parsing ────────────────────────────────────────────────────────
|
||||||
|
INSTALL_MODE="user"
|
||||||
|
VAULT_PATH=""
|
||||||
|
INTERVAL=""
|
||||||
|
FORCE=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--user)
|
||||||
|
INSTALL_MODE="user"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--system)
|
||||||
|
INSTALL_MODE="system"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--vault)
|
||||||
|
if [[ -z "${2:-}" ]]; then
|
||||||
|
echo "Error: --vault requires a path argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VAULT_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--interval)
|
||||||
|
if [[ -z "${2:-}" ]]; then
|
||||||
|
echo "Error: --interval requires a numeric argument" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
INTERVAL="$2"
|
||||||
|
if ! [[ "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
||||||
|
echo "Error: --interval requires a positive integer, got '$INTERVAL'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force|-f)
|
||||||
|
FORCE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cat <<EOF
|
||||||
|
Usage: install.sh [--user|--system] [--vault <path>] [--interval <N>] [--force]
|
||||||
|
|
||||||
|
--user Install as a user systemd service (default, ~/.config/systemd/user/)
|
||||||
|
--system Install as a system systemd service (/etc/systemd/system/)
|
||||||
|
--vault Path to the vault directory (prompted if omitted)
|
||||||
|
--interval Poll CouchDB every N seconds instead of using the _changes feed
|
||||||
|
--force Overwrite existing service unit without prompting
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: Unknown argument: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Vault path ──────────────────────────────────────────────────────────────
|
||||||
|
if [[ -z "$VAULT_PATH" ]]; then
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "Error: --vault is required in non-interactive mode" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'Vault path: '
|
||||||
|
read -r VAULT_PATH
|
||||||
|
fi
|
||||||
|
|
||||||
|
_orig_vault="$VAULT_PATH"
|
||||||
|
if ! VAULT_PATH="$(cd -- "$VAULT_PATH" 2>/dev/null && pwd)"; then
|
||||||
|
echo "Error: vault directory does not exist: $_orig_vault" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] Vault: $VAULT_PATH"
|
||||||
|
echo "[INFO] Install mode: $INSTALL_MODE"
|
||||||
|
|
||||||
|
# ── Build ────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[INFO] Building CLI from $REPO_ROOT..."
|
||||||
|
(cd "$REPO_ROOT" && npm install --silent)
|
||||||
|
(cd "$CLI_DIR" && npm run build)
|
||||||
|
|
||||||
|
BUILT_CJS="$CLI_DIR/dist/index.cjs"
|
||||||
|
if [[ ! -f "$BUILT_CJS" ]]; then
|
||||||
|
echo "Error: build output not found: $BUILT_CJS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install binary ───────────────────────────────────────────────────────────
|
||||||
|
if [[ "$INSTALL_MODE" == "user" ]]; then
|
||||||
|
BIN_DIR="$HOME/.local/bin"
|
||||||
|
UNIT_DIR="$HOME/.config/systemd/user"
|
||||||
|
SYSTEMCTL_FLAGS="--user"
|
||||||
|
else
|
||||||
|
BIN_DIR="/usr/local/bin"
|
||||||
|
UNIT_DIR="/etc/systemd/system"
|
||||||
|
SYSTEMCTL_FLAGS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
|
||||||
|
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
|
||||||
|
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
|
||||||
|
|
||||||
|
# Copy the CJS bundle so the wrapper is self-contained and independent of the
|
||||||
|
# build directory location.
|
||||||
|
cp "$BUILT_CJS" "$LIVESYNC_JS"
|
||||||
|
|
||||||
|
# Write a bash wrapper that invokes node on the installed bundle.
|
||||||
|
cat > "$LIVESYNC_BIN" <<WRAPPER
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec node "$LIVESYNC_JS" "\$@"
|
||||||
|
WRAPPER
|
||||||
|
chmod +x "$LIVESYNC_BIN"
|
||||||
|
echo "[INFO] Installed bundle: $LIVESYNC_JS"
|
||||||
|
echo "[INFO] Installed binary: $LIVESYNC_BIN"
|
||||||
|
|
||||||
|
# ── Write systemd unit ───────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
UNIT_PATH="$UNIT_DIR/livesync-cli.service"
|
||||||
|
|
||||||
|
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\""
|
||||||
|
if [[ -n "$INTERVAL" ]]; then
|
||||||
|
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\" --interval $INTERVAL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for existing service and offer to overwrite.
|
||||||
|
if [[ -f "$UNIT_PATH" ]] && [[ "$FORCE" -eq 0 ]]; then
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
echo "Error: service unit already exists at $UNIT_PATH; use --force to overwrite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'Service unit already exists at %s. Overwrite? [y/N]: ' "$UNIT_PATH"
|
||||||
|
read -r CONFIRM
|
||||||
|
case "$CONFIRM" in
|
||||||
|
[yY]|[yY][eE][sS]) : ;;
|
||||||
|
*)
|
||||||
|
echo "[INFO] Aborted. Existing unit left in place."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In awk gsub(), '&' in the replacement means "matched text"; escape any literal '&'
|
||||||
|
# in path variables before passing them as awk replacement strings.
|
||||||
|
AWK_BIN="${LIVESYNC_BIN//&/\\&}"
|
||||||
|
AWK_VAULT="${VAULT_PATH//&/\\&}"
|
||||||
|
awk -v bin="$AWK_BIN" -v vault="$AWK_VAULT" -v exec_start="ExecStart=$EXEC_START" \
|
||||||
|
'/^ExecStart=/ { print exec_start; next } {gsub("LIVESYNC_BIN", bin); gsub("LIVESYNC_VAULT_PATH", vault); print}' \
|
||||||
|
"$SERVICE_TEMPLATE" > "$UNIT_PATH"
|
||||||
|
|
||||||
|
echo "[INFO] Installed unit: $UNIT_PATH"
|
||||||
|
|
||||||
|
# ── Enable service ───────────────────────────────────────────────────────────
|
||||||
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
|
echo "[WARN] systemctl not found — skipping service activation"
|
||||||
|
echo "[INFO] To enable manually, copy $UNIT_PATH to the correct systemd directory and run:"
|
||||||
|
echo " systemctl $SYSTEMCTL_FLAGS daemon-reload"
|
||||||
|
echo " systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl $SYSTEMCTL_FLAGS daemon-reload
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[Done] livesync-cli service installed and started."
|
||||||
|
echo ""
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
|
||||||
17
src/apps/cli/deploy/livesync-cli.service
Normal file
17
src/apps/cli/deploy/livesync-cli.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Self-hosted LiveSync CLI Daemon
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
TimeoutStartSec=300
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -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";
|
||||||
@@ -26,6 +25,8 @@ import { VALID_COMMANDS } from "./commands/types";
|
|||||||
import type { CLICommand, CLIOptions } from "./commands/types";
|
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 { useP2PReplicatorFeature } from "@/lib/src/replication/trystero/useP2PReplicatorFeature";
|
||||||
|
|
||||||
const SETTINGS_FILE = ".livesync/settings.json";
|
const SETTINGS_FILE = ".livesync/settings.json";
|
||||||
ensureGlobalNodeLocalStorage();
|
ensureGlobalNodeLocalStorage();
|
||||||
@@ -43,6 +44,7 @@ Arguments:
|
|||||||
database-path Path to the local database directory
|
database-path Path to the local database directory
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||||
sync Run one replication cycle and exit
|
sync Run one replication cycle and exit
|
||||||
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
||||||
p2p-sync <peer> <timeout>
|
p2p-sync <peer> <timeout>
|
||||||
@@ -60,7 +62,13 @@ 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)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||||
|
livesync-cli ./my-database --interval 30 Run daemon (polling every 30s)
|
||||||
livesync-cli ./my-database sync
|
livesync-cli ./my-database sync
|
||||||
livesync-cli ./my-database p2p-peers 5
|
livesync-cli ./my-database p2p-peers 5
|
||||||
livesync-cli ./my-database p2p-sync my-peer-name 15
|
livesync-cli ./my-database p2p-sync my-peer-name 15
|
||||||
@@ -94,6 +102,7 @@ export function parseArgs(): CLIOptions {
|
|||||||
let verbose = false;
|
let verbose = false;
|
||||||
let debug = false;
|
let debug = false;
|
||||||
let force = false;
|
let force = false;
|
||||||
|
let interval: number | undefined;
|
||||||
let command: CLICommand = "daemon";
|
let command: CLICommand = "daemon";
|
||||||
const commandArgs: string[] = [];
|
const commandArgs: string[] = [];
|
||||||
|
|
||||||
@@ -110,6 +119,21 @@ export function parseArgs(): CLIOptions {
|
|||||||
settingsPath = args[i];
|
settingsPath = args[i];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "--interval":
|
||||||
|
case "-i": {
|
||||||
|
i++;
|
||||||
|
if (!args[i]) {
|
||||||
|
console.error(`Error: Missing value for ${token}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const n = parseInt(args[i], 10);
|
||||||
|
if (!Number.isInteger(n) || n <= 0) {
|
||||||
|
console.error(`Error: --interval requires a positive integer, got '${args[i]}'`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
interval = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "--debug":
|
case "--debug":
|
||||||
case "-d":
|
case "-d":
|
||||||
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
||||||
@@ -164,6 +188,7 @@ export function parseArgs(): CLIOptions {
|
|||||||
force,
|
force,
|
||||||
command,
|
command,
|
||||||
commandArgs,
|
commandArgs,
|
||||||
|
interval,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +222,9 @@ async function createDefaultSettingsFile(options: CLIOptions) {
|
|||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
const options = parseArgs();
|
const options = parseArgs();
|
||||||
|
if (options.interval && options.command !== "daemon") {
|
||||||
|
console.error(`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||||
|
}
|
||||||
const avoidStdoutNoise =
|
const avoidStdoutNoise =
|
||||||
options.command === "cat" ||
|
options.command === "cat" ||
|
||||||
options.command === "cat-rev" ||
|
options.command === "cat-rev" ||
|
||||||
@@ -248,6 +276,17 @@ export async function main() {
|
|||||||
infoLog(`Settings: ${settingsPath}`);
|
infoLog(`Settings: ${settingsPath}`);
|
||||||
infoLog("");
|
infoLog("");
|
||||||
|
|
||||||
|
// For daemon and mirror mode, load ignore rules before the core is constructed so that
|
||||||
|
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
||||||
|
const watchEnabled = options.command === "daemon";
|
||||||
|
const vaultPath =
|
||||||
|
options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath;
|
||||||
|
let ignoreRules: IgnoreRules | undefined;
|
||||||
|
if (options.command === "daemon" || options.command === "mirror") {
|
||||||
|
ignoreRules = new IgnoreRules(vaultPath);
|
||||||
|
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);
|
||||||
@@ -278,11 +317,14 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
console.error(`${prefix} ${message}`);
|
console.error(`${prefix} ${message}`);
|
||||||
});
|
});
|
||||||
// Prevent replication result to be processed automatically.
|
// Prevent replication result from being processed automatically in non-daemon commands.
|
||||||
|
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||||
|
if (options.command !== "daemon") {
|
||||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||||
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||||
return await Promise.resolve(true);
|
return await Promise.resolve(true);
|
||||||
}, -100);
|
}, -100);
|
||||||
|
}
|
||||||
|
|
||||||
// Setup settings handlers
|
// Setup settings handlers
|
||||||
const settingService = serviceHubInstance.setting;
|
const settingService = serviceHubInstance.setting;
|
||||||
@@ -324,18 +366,13 @@ export async function main() {
|
|||||||
const core = new LiveSyncBaseCore(
|
const core = new LiveSyncBaseCore(
|
||||||
serviceHubInstance,
|
serviceHubInstance,
|
||||||
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||||
const mirrorVaultPath =
|
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
||||||
options.command === "mirror" && options.commandArgs[0]
|
|
||||||
? path.resolve(options.commandArgs[0])
|
|
||||||
: databasePath;
|
|
||||||
return initialiseServiceModulesCLI(mirrorVaultPath, core, serviceHub);
|
|
||||||
},
|
},
|
||||||
(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));
|
||||||
@@ -344,8 +381,25 @@ export async function main() {
|
|||||||
if (parts.some((part) => part.startsWith("."))) {
|
if (parts.some((part) => part.startsWith("."))) {
|
||||||
return await Promise.resolve(false);
|
return await Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
// PouchDB LevelDB database directory lives in the vault directory.
|
||||||
|
if (parts[0]?.endsWith("-livesync-v2")) {
|
||||||
|
return await Promise.resolve(false);
|
||||||
|
}
|
||||||
return await Promise.resolve(true);
|
return await Promise.resolve(true);
|
||||||
}, -1 /* highest priority */);
|
}, -1 /* highest priority */);
|
||||||
|
|
||||||
|
// Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check).
|
||||||
|
if (ignoreRules) {
|
||||||
|
const rules = ignoreRules;
|
||||||
|
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||||
|
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||||
|
if (rules.shouldIgnore(targetPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// undefined = pass through to next handler in chain
|
||||||
|
return undefined;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -366,6 +420,25 @@ export async function main() {
|
|||||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||||
|
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||||
|
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||||
|
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||||
|
|
||||||
|
// Restore settings file on any exit to undo lifecycle mutations.
|
||||||
|
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||||
|
process.on("exit", () => {
|
||||||
|
if (settingsBackup) {
|
||||||
|
const tmpPath = settingsPath + ".tmp";
|
||||||
|
try {
|
||||||
|
require("fs").writeFileSync(tmpPath, settingsBackup, "utf-8");
|
||||||
|
require("fs").renameSync(tmpPath, settingsPath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[Settings] Failed to restore settings on exit:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Start the core
|
// Start the core
|
||||||
try {
|
try {
|
||||||
infoLog(`[Starting] Initializing LiveSync...`);
|
infoLog(`[Starting] Initializing LiveSync...`);
|
||||||
@@ -375,6 +448,18 @@ export async function main() {
|
|||||||
console.error(`[Error] Failed to initialize LiveSync`);
|
console.error(`[Error] Failed to initialize LiveSync`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
// Capture sync settings before suspendAllSync() clobbers them.
|
||||||
|
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
||||||
|
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
||||||
|
const originalSyncSettings = {
|
||||||
|
liveSync: settingsBeforeSuspend.liveSync,
|
||||||
|
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
||||||
|
periodicReplication: settingsBeforeSuspend.periodicReplication,
|
||||||
|
syncOnSave: settingsBeforeSuspend.syncOnSave,
|
||||||
|
syncOnEditorSave: settingsBeforeSuspend.syncOnEditorSave,
|
||||||
|
syncOnFileOpen: settingsBeforeSuspend.syncOnFileOpen,
|
||||||
|
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
||||||
|
};
|
||||||
await core.services.setting.suspendAllSync();
|
await core.services.setting.suspendAllSync();
|
||||||
await core.services.control.onReady();
|
await core.services.control.onReady();
|
||||||
|
|
||||||
@@ -400,7 +485,7 @@ export async function main() {
|
|||||||
infoLog("");
|
infoLog("");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await runCommand(options, { databasePath, core, settingsPath });
|
const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings });
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.error(`[Error] Command '${options.command}' failed`);
|
console.error(`[Error] Command '${options.command}' failed`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -408,7 +493,7 @@ export async function main() {
|
|||||||
infoLog(`[Done] Command '${options.command}' completed`);
|
infoLog(`[Done] Command '${options.command}' completed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.command === "daemon") {
|
if (options.command === "daemon" && result) {
|
||||||
// Keep the process running
|
// Keep the process running
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -85,4 +85,67 @@ describe("CLI parseArgs", () => {
|
|||||||
expect(parsed.command).toBe("p2p-host");
|
expect(parsed.command).toBe("p2p-host");
|
||||||
expect(parsed.commandArgs).toEqual([]);
|
expect(parsed.commandArgs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses --interval flag with valid integer", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
expect(parsed.interval).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses -i shorthand for --interval", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "-i", "10"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.interval).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval has no value", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
expect(exitMock).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval is not a positive integer", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
expect(exitMock).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval is negative", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits 1 when --interval is not numeric", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"];
|
||||||
|
const exitMock = mockProcessExit();
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit daemon command", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "daemon"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
expect(parsed.databasePath).toBe("./vault");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to daemon when no command specified", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses explicit daemon command with --interval", () => {
|
||||||
|
process.argv = ["node", "livesync-cli", "./vault", "daemon", "--interval", "30"];
|
||||||
|
const parsed = parseArgs();
|
||||||
|
expect(parsed.command).toBe("daemon");
|
||||||
|
expect(parsed.interval).toBe(30);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import type {
|
|||||||
} from "@lib/managers/adapters";
|
} from "@lib/managers/adapters";
|
||||||
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
||||||
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
||||||
|
import type { Stats } from "fs";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||||
|
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific type guard adapter
|
* CLI-specific type guard adapter
|
||||||
@@ -56,22 +59,11 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific status adapter (console logging)
|
* CLI-specific status adapter (no-op — daemon uses journald for status)
|
||||||
*/
|
*/
|
||||||
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
||||||
private lastUpdate = 0;
|
updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void {
|
||||||
private updateInterval = 5000; // Update every 5 seconds
|
// intentional no-op
|
||||||
|
|
||||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastUpdate > this.updateInterval) {
|
|
||||||
if (status.totalQueued > 0 || status.processing > 0) {
|
|
||||||
// console.log(
|
|
||||||
// `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}`
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
this.lastUpdate = now;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,15 +92,101 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific watch adapter (optional file watching with chokidar)
|
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
||||||
*/
|
*/
|
||||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||||
constructor(private basePath: string) {}
|
private _watcher: FSWatcher | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private basePath: string,
|
||||||
|
private ignoreRules?: IgnoreRules,
|
||||||
|
private watchEnabled: boolean = false
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||||
|
return {
|
||||||
|
path: path.relative(this.basePath, filePath) as FilePath,
|
||||||
|
stat: {
|
||||||
|
ctime: stats?.ctimeMs ?? Date.now(),
|
||||||
|
mtime: stats?.mtimeMs ?? Date.now(),
|
||||||
|
size: stats?.size ?? 0,
|
||||||
|
type: "file",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toNodeFolder(dirPath: string): NodeFolder {
|
||||||
|
return {
|
||||||
|
path: path.relative(this.basePath, dirPath) as FilePath,
|
||||||
|
isFolder: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||||
// File watching is not activated in the CLI.
|
if (!this.watchEnabled) return;
|
||||||
// Because the CLI is designed for push/pull operations, not real-time sync.
|
const baseIgnored: Array<RegExp | string | ((p: string) => boolean)> = [
|
||||||
// console.error("[CLIWatchAdapter] File watching is not enabled in CLI version");
|
/(^|[/\\])\./,
|
||||||
|
/(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/,
|
||||||
|
];
|
||||||
|
// Bind rules to a local const before the closure — chokidar v4 requires a
|
||||||
|
// MatchFunction, not glob strings, for custom patterns.
|
||||||
|
const rules = this.ignoreRules;
|
||||||
|
const ignored = rules
|
||||||
|
? [...baseIgnored, (p: string) => rules.shouldIgnore(path.relative(this.basePath, p))]
|
||||||
|
: baseIgnored;
|
||||||
|
|
||||||
|
const watcher = chokidarWatch(this.basePath, {
|
||||||
|
ignored,
|
||||||
|
ignoreInitial: true,
|
||||||
|
persistent: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 500,
|
||||||
|
pollInterval: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("add", (filePath, stats) => {
|
||||||
|
const nodeFile = this._toNodeFile(filePath, stats);
|
||||||
|
handlers.onCreate(nodeFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("change", (filePath, stats) => {
|
||||||
|
const nodeFile = this._toNodeFile(filePath, stats);
|
||||||
|
handlers.onChange(nodeFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("unlink", (filePath) => {
|
||||||
|
const nodeFile = this._toNodeFile(filePath, undefined);
|
||||||
|
handlers.onDelete(nodeFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("addDir", (dirPath) => {
|
||||||
|
const nodeFolder = this._toNodeFolder(dirPath);
|
||||||
|
handlers.onCreate(nodeFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("unlinkDir", (dirPath) => {
|
||||||
|
const nodeFolder = this._toNodeFolder(dirPath);
|
||||||
|
handlers.onDelete(nodeFolder);
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on("error", (err) => {
|
||||||
|
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
|
||||||
|
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
|
||||||
|
void watcher.close();
|
||||||
|
this._watcher = undefined;
|
||||||
|
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
||||||
|
this._watcher = watcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
if (this._watcher) {
|
||||||
|
return this._watcher.close();
|
||||||
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,11 +201,15 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
|||||||
readonly status: CLIStatusAdapter;
|
readonly status: CLIStatusAdapter;
|
||||||
readonly converter: CLIConverterAdapter;
|
readonly converter: CLIConverterAdapter;
|
||||||
|
|
||||||
constructor(basePath: string) {
|
constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) {
|
||||||
this.typeGuard = new CLITypeGuardAdapter();
|
this.typeGuard = new CLITypeGuardAdapter();
|
||||||
this.persistence = new CLIPersistenceAdapter(basePath);
|
this.persistence = new CLIPersistenceAdapter(basePath);
|
||||||
this.watch = new CLIWatchAdapter(basePath);
|
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled);
|
||||||
this.status = new CLIStatusAdapter();
|
this.status = new CLIStatusAdapter();
|
||||||
this.converter = new CLIConverterAdapter();
|
this.converter = new CLIConverterAdapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.watch.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
123
src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import type { IStorageEventWatchHandlers } from "@lib/managers/adapters";
|
||||||
|
import type { NodeFile } from "../adapters/NodeTypes";
|
||||||
|
|
||||||
|
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||||
|
// Must be hoisted before imports that pull in chokidar.
|
||||||
|
|
||||||
|
const mockWatcher = {
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
once: vi.fn((event: string, cb: () => void) => {
|
||||||
|
if (event === "ready") cb();
|
||||||
|
return mockWatcher;
|
||||||
|
}),
|
||||||
|
close: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("chokidar", () => ({
|
||||||
|
watch: vi.fn(() => mockWatcher),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as chokidar from "chokidar";
|
||||||
|
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeHandlers(): IStorageEventWatchHandlers {
|
||||||
|
return {
|
||||||
|
onCreate: vi.fn(),
|
||||||
|
onChange: vi.fn(),
|
||||||
|
onDelete: vi.fn(),
|
||||||
|
onRename: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("CLIStorageEventManagerAdapter", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Restore the default once() behaviour (ready fires synchronously).
|
||||||
|
mockWatcher.once.mockImplementation((event: string, cb: () => void) => {
|
||||||
|
if (event === "ready") cb();
|
||||||
|
return mockWatcher;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
expect(chokidar.watch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beginWatch calls chokidar.watch when watchEnabled=true", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||||
|
const basePath = "/vault/base";
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
// Find the callback registered for the "add" event.
|
||||||
|
const addCall = mockWatcher.on.mock.calls.find(([event]) => event === "add");
|
||||||
|
expect(addCall).toBeDefined();
|
||||||
|
const addCallback = addCall![1] as (filePath: string, stats: any) => void;
|
||||||
|
|
||||||
|
const fakeStats = { ctimeMs: 1000, mtimeMs: 2000, size: 42 };
|
||||||
|
addCallback(`${basePath}/subdir/note.md`, fakeStats);
|
||||||
|
|
||||||
|
expect(handlers.onCreate).toHaveBeenCalledTimes(1);
|
||||||
|
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||||
|
expect(created.path).toBe("subdir/note.md");
|
||||||
|
expect(created.stat?.size).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() calls watcher.close()", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
await adapter.close();
|
||||||
|
|
||||||
|
expect(mockWatcher.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() is safe when no watcher was started", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||||
|
|
||||||
|
// Should not throw.
|
||||||
|
await expect(adapter.close()).resolves.toBeUndefined();
|
||||||
|
expect(mockWatcher.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error event triggers process.exit(1)", async () => {
|
||||||
|
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||||
|
const handlers = makeHandlers();
|
||||||
|
|
||||||
|
await adapter.watch.beginWatch(handlers);
|
||||||
|
|
||||||
|
const processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||||
|
|
||||||
|
const errorCall = mockWatcher.on.mock.calls.find(([event]) => event === "error");
|
||||||
|
expect(errorCall).toBeDefined();
|
||||||
|
const errorCallback = errorCall![1] as (err: Error) => void;
|
||||||
|
|
||||||
|
errorCallback(new Error("disk failure"));
|
||||||
|
|
||||||
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||||
|
|
||||||
|
processExitSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } fro
|
|||||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||||
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||||
|
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||||
// import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService";
|
// import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService";
|
||||||
|
|
||||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||||
@@ -10,9 +11,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
|||||||
constructor(
|
constructor(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||||
dependencies: StorageEventManagerBaseDependencies
|
dependencies: StorageEventManagerBaseDependencies,
|
||||||
|
ignoreRules?: IgnoreRules,
|
||||||
|
watchEnabled?: boolean
|
||||||
) {
|
) {
|
||||||
const adapter = new CLIStorageEventManagerAdapter(basePath);
|
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled);
|
||||||
super(adapter, dependencies);
|
super(adapter, dependencies);
|
||||||
this.core = core;
|
this.core = core;
|
||||||
}
|
}
|
||||||
@@ -25,4 +28,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
|||||||
// No-op in CLI version
|
// No-op in CLI version
|
||||||
// Internal file handling is not needed
|
// Internal file handling is not needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the file watcher. Call this during graceful shutdown.
|
||||||
|
*/
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.adapter.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/check-submodule.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"cli": "node dist/index.cjs",
|
"cli": "node dist/index.cjs",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"werift": "^0.22.9",
|
"werift": "^0.22.9",
|
||||||
"pouchdb-adapter-http": "^9.0.0",
|
"pouchdb-adapter-http": "^9.0.0",
|
||||||
|
|||||||
36
src/apps/cli/scripts/check-submodule.mjs
Normal file
36
src/apps/cli/scripts/check-submodule.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
const cliDir = process.cwd();
|
||||||
|
const repoRoot = path.resolve(cliDir, "../../..");
|
||||||
|
const requiredFiles = [
|
||||||
|
path.join(repoRoot, "src/lib/src/common/types.ts"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingFiles = requiredFiles.filter((filePath) => !fs.existsSync(filePath));
|
||||||
|
|
||||||
|
if (missingFiles.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[CLI Build Error] Required shared sources were not found.");
|
||||||
|
console.error("This repository uses Git submodules, and the CLI depends on src/lib.");
|
||||||
|
console.error("");
|
||||||
|
console.error("Missing file(s):");
|
||||||
|
for (const filePath of missingFiles) {
|
||||||
|
console.error(` - ${path.relative(repoRoot, filePath)}`);
|
||||||
|
}
|
||||||
|
console.error("");
|
||||||
|
console.error("Initialize submodules, then retry the CLI build:");
|
||||||
|
console.error(" git submodule update --init --recursive");
|
||||||
|
console.error("");
|
||||||
|
console.error("For a fresh clone, prefer:");
|
||||||
|
console.error(" git clone --recurse-submodules <repository-url>");
|
||||||
|
console.error("");
|
||||||
|
console.error("Then run:");
|
||||||
|
console.error(" npm install");
|
||||||
|
console.error(" cd src/apps/cli");
|
||||||
|
console.error(" npm run build");
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
@@ -9,6 +9,7 @@ import { ServiceFileAccessCLI } from "./ServiceFileAccessImpl";
|
|||||||
import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||||
import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI";
|
import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI";
|
||||||
import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
||||||
|
import type { IgnoreRules } from "./IgnoreRules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize service modules for CLI version
|
* Initialize service modules for CLI version
|
||||||
@@ -22,7 +23,9 @@ import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
|||||||
export function initialiseServiceModulesCLI(
|
export function initialiseServiceModulesCLI(
|
||||||
basePath: string,
|
basePath: string,
|
||||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||||
services: InjectableServiceHub<ServiceContext>
|
services: InjectableServiceHub<ServiceContext>,
|
||||||
|
ignoreRules?: IgnoreRules,
|
||||||
|
watchEnabled: boolean = false
|
||||||
): ServiceModules {
|
): ServiceModules {
|
||||||
const storageAccessManager = new StorageAccessManager();
|
const storageAccessManager = new StorageAccessManager();
|
||||||
|
|
||||||
@@ -36,12 +39,24 @@ 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
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||||
|
services.appLifecycle.onUnload.addHandler(async () => {
|
||||||
|
await storageEventManager.close();
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Storage access using CLI file system adapter
|
// Storage access using CLI file system adapter
|
||||||
|
|||||||
131
src/apps/cli/serviceModules/IgnoreRules.ts
Normal file
131
src/apps/cli/serviceModules/IgnoreRules.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import * as fs from "fs/promises";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
import { minimatch } from "minimatch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and evaluates ignore rules from `.livesync/ignore` inside the vault.
|
||||||
|
*
|
||||||
|
* File format:
|
||||||
|
* - Lines starting with `#` are comments.
|
||||||
|
* - Blank lines are ignored.
|
||||||
|
* - `import: .gitignore` (exactly) — merges patterns from the vault's `.gitignore`.
|
||||||
|
* - All other lines are minimatch glob patterns relative to the vault root.
|
||||||
|
*
|
||||||
|
* Negation patterns (lines starting with `!`) are not supported. Loading a
|
||||||
|
* ruleset containing them throws an error — use separate include/exclude files
|
||||||
|
* instead.
|
||||||
|
*
|
||||||
|
* Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped.
|
||||||
|
*/
|
||||||
|
export class IgnoreRules {
|
||||||
|
private patterns: string[] = [];
|
||||||
|
|
||||||
|
constructor(private vaultPath: string) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the
|
||||||
|
* pattern list. Safe to call multiple times — each call replaces the
|
||||||
|
* previous state. Does not throw if files are absent.
|
||||||
|
*
|
||||||
|
* @throws if any pattern line begins with `!` (negation is unsupported).
|
||||||
|
*/
|
||||||
|
async load(): Promise<void> {
|
||||||
|
this.patterns = [];
|
||||||
|
const ignorePath = path.join(this.vaultPath, ".livesync", "ignore");
|
||||||
|
let rawLines: string[];
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(ignorePath, "utf-8");
|
||||||
|
rawLines = content.split(/\r?\n/);
|
||||||
|
} catch {
|
||||||
|
// File absent or unreadable — treat as empty ruleset.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of rawLines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// NOTE: Only the exact string "import: .gitignore" is recognised.
|
||||||
|
// Any future generalisation of this directive must validate that
|
||||||
|
// the resolved path stays within the vault directory.
|
||||||
|
if (trimmed === "import: .gitignore") {
|
||||||
|
await this._importGitignore();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("import:")) {
|
||||||
|
console.error(
|
||||||
|
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this._addPattern(trimmed);
|
||||||
|
}
|
||||||
|
if (this.patterns.length > 0) {
|
||||||
|
console.error(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalises a single gitignore-style pattern:
|
||||||
|
// - Patterns ending with `/` (directory patterns like `build/`) are
|
||||||
|
// converted to `build/**` so they match all files inside that directory.
|
||||||
|
// - Patterns without a `/` are prefixed with `**/` to give them matchBase
|
||||||
|
// semantics (e.g. `*.tmp` → `**/*.tmp`), matching the basename in any
|
||||||
|
// subdirectory as gitignore does.
|
||||||
|
// - Patterns that already contain a `/` (but don't end with one) are
|
||||||
|
// path-specific and used as-is.
|
||||||
|
private _normalisePattern(pattern: string): string {
|
||||||
|
if (pattern.endsWith("/")) {
|
||||||
|
return "**/" + pattern + "**";
|
||||||
|
} else if (!pattern.includes("/")) {
|
||||||
|
return "**/" + pattern;
|
||||||
|
}
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _importGitignore(): Promise<void> {
|
||||||
|
const gitignorePath = path.join(this.vaultPath, ".gitignore");
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(gitignorePath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._parseLines(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseLines(content: string): void {
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
this._addPattern(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addPattern(raw: string): void {
|
||||||
|
if (raw.startsWith("!")) {
|
||||||
|
throw new Error(
|
||||||
|
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
|
||||||
|
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.patterns.push(this._normalisePattern(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` if the given vault-relative path matches any loaded
|
||||||
|
* ignore pattern.
|
||||||
|
*
|
||||||
|
* @param relativePath - Path relative to the vault root, using forward
|
||||||
|
* slashes or the OS separator.
|
||||||
|
*/
|
||||||
|
shouldIgnore(relativePath: string): boolean {
|
||||||
|
if (this.patterns.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Normalise to forward slashes for minimatch.
|
||||||
|
const normalised = relativePath.replace(/\\/g, "/");
|
||||||
|
return this.patterns.some((p) => minimatch(normalised, p, { dot: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts
Normal file
169
src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { IgnoreRules } from "./IgnoreRules";
|
||||||
|
|
||||||
|
describe("IgnoreRules", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function createVault(): Promise<string> {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-ignorerules-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIgnoreFile(vaultPath: string, content: string): Promise<void> {
|
||||||
|
const ignoreDir = path.join(vaultPath, ".livesync");
|
||||||
|
await fs.mkdir(ignoreDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pattern normalisation", () => {
|
||||||
|
it("adds **/ prefix to basename patterns (no slash)", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("deep/nested/file.tmp")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends ** to directory patterns ending with / and prepends **/", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "build/\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("build/nested/file.js")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("subproject/build/output.js")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves patterns containing / as-is", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "docs/private.md\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("docs/private.md")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("other/docs/private.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldIgnore", () => {
|
||||||
|
it("matches **/*.tmp against notes/scratch.tmp", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match notes/readme.md against **/*.tmp", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("notes/readme.md")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no patterns are loaded", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
// No load() call — patterns are empty
|
||||||
|
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("negation patterns", () => {
|
||||||
|
it("throws when a negation pattern is encountered", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\n!important.tmp\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when a .gitignore imported via directive contains negation", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||||
|
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n!keep.log\n", "utf-8");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unrecognised import: directives", () => {
|
||||||
|
it("warns and skips unrecognised import: forms (does not add as literal pattern)", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
// Typo: "import:.gitignore" instead of "import: .gitignore"
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
// *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern)
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("import:.gitignore")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("load() with missing file", () => {
|
||||||
|
it("returns without error when .livesync/ignore is absent", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
// No ignore file created
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).resolves.toBeUndefined();
|
||||||
|
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("load() with comments and blank lines", () => {
|
||||||
|
it("skips # comment lines and blank lines", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("readme.md")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import: .gitignore directive", () => {
|
||||||
|
it("reads and normalises patterns from .gitignore", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||||
|
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\nnode_modules/\n", "utf-8");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("app.log")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("node_modules/package.json")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("src/node_modules/package.json")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("src/index.ts")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges .gitignore patterns with other patterns", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||||
|
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n", "utf-8");
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await rules.load();
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
expect(rules.shouldIgnore("error.log")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("import: .gitignore with missing .gitignore", () => {
|
||||||
|
it("does not throw when .gitignore is absent", async () => {
|
||||||
|
const vaultPath = await createVault();
|
||||||
|
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||||
|
// No .gitignore created
|
||||||
|
const rules = new IgnoreRules(vaultPath);
|
||||||
|
await expect(rules.load()).resolves.toBeUndefined();
|
||||||
|
// The *.tmp pattern from the ignore file still works
|
||||||
|
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
166
src/apps/cli/test/test-daemon-linux.sh
Executable file
166
src/apps/cli/test/test-daemon-linux.sh
Executable file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test: daemon-related ignore rules behaviour
|
||||||
|
#
|
||||||
|
# Tests that are runnable without a long-running daemon process are exercised
|
||||||
|
# here using the `mirror` command, which calls the same `isTargetFile` handler
|
||||||
|
# stack that the daemon uses.
|
||||||
|
#
|
||||||
|
# Covered cases:
|
||||||
|
# 1. .livesync/ignore with *.tmp pattern → ignored file is NOT synced to DB
|
||||||
|
# 2. .livesync/ignore missing → no error, normal sync continues
|
||||||
|
# 3. import: .gitignore directive → patterns from .gitignore are merged
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$CLI_DIR"
|
||||||
|
source "$SCRIPT_DIR/test-helpers.sh"
|
||||||
|
display_test_info
|
||||||
|
|
||||||
|
RUN_BUILD="${RUN_BUILD:-1}"
|
||||||
|
cli_test_init_cli_cmd
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-daemon-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
|
SETTINGS_FILE="$WORK_DIR/data.json"
|
||||||
|
VAULT_DIR="$WORK_DIR/vault"
|
||||||
|
mkdir -p "$VAULT_DIR/notes"
|
||||||
|
|
||||||
|
if [[ "$RUN_BUILD" == "1" ]]; then
|
||||||
|
echo "[INFO] building CLI..."
|
||||||
|
npm run build
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[INFO] generating settings -> $SETTINGS_FILE"
|
||||||
|
cli_test_init_settings_file "$SETTINGS_FILE"
|
||||||
|
cli_test_mark_settings_configured "$SETTINGS_FILE"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_pass() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
|
||||||
|
assert_fail() { echo "[FAIL] $1" >&2; FAIL=$((FAIL + 1)); }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Case 1: .livesync/ignore with *.tmp → matched file should NOT appear in DB
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Case 1: .livesync/ignore *.tmp → ignored file not synced to DB ==="
|
||||||
|
|
||||||
|
mkdir -p "$VAULT_DIR/.livesync"
|
||||||
|
printf '*.tmp\n' > "$VAULT_DIR/.livesync/ignore"
|
||||||
|
|
||||||
|
# Also write a normal file so we can confirm mirror ran at all.
|
||||||
|
printf 'normal content\n' > "$VAULT_DIR/notes/normal.md"
|
||||||
|
# Write the file that should be ignored.
|
||||||
|
printf 'tmp content\n' > "$VAULT_DIR/notes/scratch.tmp"
|
||||||
|
|
||||||
|
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror
|
||||||
|
|
||||||
|
# The normal file should be in the DB.
|
||||||
|
RESULT_NORMAL="$WORK_DIR/case1-normal.txt"
|
||||||
|
if run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" pull notes/normal.md "$RESULT_NORMAL" 2>/dev/null; then
|
||||||
|
if cmp -s "$VAULT_DIR/notes/normal.md" "$RESULT_NORMAL"; then
|
||||||
|
assert_pass "normal.md was synced to DB"
|
||||||
|
else
|
||||||
|
assert_fail "normal.md content mismatch after mirror"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
assert_fail "normal.md was not found in DB after mirror"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The .tmp file should NOT be in the DB.
|
||||||
|
DB_LIST="$WORK_DIR/case1-ls.txt"
|
||||||
|
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" ls > "$DB_LIST"
|
||||||
|
if grep -q "scratch.tmp" "$DB_LIST"; then
|
||||||
|
assert_fail "scratch.tmp (ignored) was unexpectedly synced to DB"
|
||||||
|
echo "--- DB listing ---" >&2; cat "$DB_LIST" >&2
|
||||||
|
else
|
||||||
|
assert_pass "scratch.tmp (*.tmp pattern) was NOT synced to DB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Case 2: .livesync/ignore absent → no error, normal sync continues
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Case 2: .livesync/ignore absent → no error, sync continues ==="
|
||||||
|
|
||||||
|
VAULT_DIR2="$WORK_DIR/vault2"
|
||||||
|
mkdir -p "$VAULT_DIR2/notes"
|
||||||
|
SETTINGS_FILE2="$WORK_DIR/data2.json"
|
||||||
|
cli_test_init_settings_file "$SETTINGS_FILE2"
|
||||||
|
cli_test_mark_settings_configured "$SETTINGS_FILE2"
|
||||||
|
|
||||||
|
# No .livesync directory at all.
|
||||||
|
printf 'hello\n' > "$VAULT_DIR2/notes/hello.md"
|
||||||
|
|
||||||
|
# mirror should succeed without error.
|
||||||
|
set +e
|
||||||
|
MIRROR_OUTPUT="$WORK_DIR/case2-mirror.txt"
|
||||||
|
run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" mirror >"$MIRROR_OUTPUT" 2>&1
|
||||||
|
MIRROR_EXIT=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$MIRROR_EXIT" -ne 0 ]]; then
|
||||||
|
assert_fail "mirror exited non-zero ($MIRROR_EXIT) when .livesync/ignore is absent"
|
||||||
|
cat "$MIRROR_OUTPUT" >&2
|
||||||
|
else
|
||||||
|
assert_pass "mirror succeeded when .livesync/ignore is absent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The normal file should have been synced.
|
||||||
|
RESULT_HELLO="$WORK_DIR/case2-hello.txt"
|
||||||
|
if run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" pull notes/hello.md "$RESULT_HELLO" 2>/dev/null; then
|
||||||
|
assert_pass "file synced normally when .livesync/ignore is absent"
|
||||||
|
else
|
||||||
|
assert_fail "file was not synced when .livesync/ignore is absent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Case 3: import: .gitignore merges patterns
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "=== Case 3: import: .gitignore directive merges patterns ==="
|
||||||
|
|
||||||
|
VAULT_DIR3="$WORK_DIR/vault3"
|
||||||
|
mkdir -p "$VAULT_DIR3/notes"
|
||||||
|
SETTINGS_FILE3="$WORK_DIR/data3.json"
|
||||||
|
cli_test_init_settings_file "$SETTINGS_FILE3"
|
||||||
|
cli_test_mark_settings_configured "$SETTINGS_FILE3"
|
||||||
|
|
||||||
|
mkdir -p "$VAULT_DIR3/.livesync"
|
||||||
|
printf 'import: .gitignore\n' > "$VAULT_DIR3/.livesync/ignore"
|
||||||
|
printf '# gitignore comment\n*.log\nbuild/\n' > "$VAULT_DIR3/.gitignore"
|
||||||
|
|
||||||
|
printf 'regular note\n' > "$VAULT_DIR3/notes/regular.md"
|
||||||
|
printf 'log content\n' > "$VAULT_DIR3/notes/debug.log"
|
||||||
|
|
||||||
|
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" mirror
|
||||||
|
|
||||||
|
DB_LIST3="$WORK_DIR/case3-ls.txt"
|
||||||
|
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" ls > "$DB_LIST3"
|
||||||
|
|
||||||
|
if grep -q "debug.log" "$DB_LIST3"; then
|
||||||
|
assert_fail "debug.log (ignored via .gitignore import) was unexpectedly synced to DB"
|
||||||
|
echo "--- DB listing ---" >&2; cat "$DB_LIST3" >&2
|
||||||
|
else
|
||||||
|
assert_pass "debug.log (*.log from imported .gitignore) was NOT synced to DB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# regular.md should still be present.
|
||||||
|
if grep -q "regular.md" "$DB_LIST3"; then
|
||||||
|
assert_pass "regular.md was synced normally alongside .gitignore import rules"
|
||||||
|
else
|
||||||
|
assert_fail "regular.md was NOT synced — .gitignore import may have been too broad"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Summary
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "Results: PASS=$PASS FAIL=$FAIL"
|
||||||
|
if [[ "$FAIL" -gt 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
9
src/apps/cli/testdeno/.test.env
Normal file
9
src/apps/cli/testdeno/.test.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
hostname=http://127.0.0.1:5989/
|
||||||
|
dbname=livesync-test-db-ci
|
||||||
|
username=admin
|
||||||
|
password=testpassword
|
||||||
|
minioEndpoint=http://127.0.0.1:9000
|
||||||
|
accessKey=minioadmin
|
||||||
|
secretKey=minioadmin
|
||||||
|
bucketName=livesync-test-bucket-ci
|
||||||
|
LIVESYNC_TEST_TEE=1
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": "deno test -A --no-check test-*.ts",
|
"test": "deno test --env-file=.test.env -A --no-check test-*.ts",
|
||||||
"test:local": "deno test -A --no-check test-setup-put-cat.ts test-mirror.ts",
|
"test:local": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts test-mirror.ts",
|
||||||
"test:push-pull": "deno test -A --no-check test-push-pull.ts",
|
"test:push-pull": "deno test --env-file=.test.env -A --no-check test-push-pull.ts",
|
||||||
"test:setup-put-cat": "deno test -A --no-check test-setup-put-cat.ts",
|
"test:setup-put-cat": "deno test --env-file=.test.env -A --no-check test-setup-put-cat.ts",
|
||||||
"test:mirror": "deno test -A --no-check test-mirror.ts",
|
"test:mirror": "deno test --env-file=.test.env -A --no-check test-mirror.ts",
|
||||||
"test:sync-two-local": "deno test -A --no-check test-sync-two-local-databases.ts",
|
"test:sync-two-local": "deno test --env-file=.test.env -A --no-check test-sync-two-local-databases.ts",
|
||||||
"test:sync-locked-remote": "deno test -A --no-check test-sync-locked-remote.ts",
|
"test:sync-locked-remote": "deno test --env-file=.test.env -A --no-check test-sync-locked-remote.ts",
|
||||||
"test:p2p-host": "deno test -A --no-check test-p2p-host.ts",
|
"test:p2p-host": "deno test --env-file=.test.env -A --no-check test-p2p-host.ts",
|
||||||
"test:p2p-peers": "deno test -A --no-check test-p2p-peers-local-relay.ts",
|
"test:p2p-peers": "deno test --env-file=.test.env -A --no-check test-p2p-peers-local-relay.ts",
|
||||||
"test:p2p-sync": "deno test -A --no-check test-p2p-sync.ts",
|
"test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts",
|
||||||
"test:p2p-three-nodes": "deno test -A --no-check test-p2p-three-nodes-conflict.ts",
|
"test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts",
|
||||||
"test:p2p-upload-download": "deno test -A --no-check test-p2p-upload-download-repro.ts",
|
"test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts",
|
||||||
"test:e2e-couchdb": "deno test -A --no-check test-e2e-two-vaults-couchdb.ts",
|
"test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts",
|
||||||
"test:e2e-matrix": "deno test -A --no-check test-e2e-two-vaults-matrix.ts"
|
"test:e2e-matrix": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-matrix.ts"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { assert } from "@std/assert";
|
import { assert } from "@std/assert";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
import {
|
import {
|
||||||
runCli,
|
runCli,
|
||||||
runCliOrFail,
|
runCliOrFail,
|
||||||
@@ -11,31 +10,30 @@ import {
|
|||||||
} from "./helpers/cli.ts";
|
} from "./helpers/cli.ts";
|
||||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
import { startCouchdb, startMinio, stopCouchdb, stopMinio } from "./helpers/docker.ts";
|
||||||
import { join } from "@std/path";
|
|
||||||
|
|
||||||
const TEST_ENV = join(import.meta.dirname!, "..", ".test.env");
|
|
||||||
type RemoteType = "COUCHDB" | "MINIO";
|
type RemoteType = "COUCHDB" | "MINIO";
|
||||||
|
|
||||||
function requireEnv(env: Record<string, string>, key: string): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
const value = env[key]?.trim();
|
for (const key of keys) {
|
||||||
if (!value) throw new Error(`Required env var is missing: ${key}`);
|
const value = Deno.env.get(key)?.trim();
|
||||||
return value;
|
if (value) return value;
|
||||||
|
}
|
||||||
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
export async function runScenario(remoteType: RemoteType, encrypt: boolean): Promise<void> {
|
||||||
const env = await loadEnvFile(TEST_ENV);
|
|
||||||
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
const dbSuffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
const couchdbUri = remoteType === "COUCHDB" ? requireEnv(env, "hostname").replace(/\/$/, "") : "";
|
const couchdbUri = remoteType === "COUCHDB" ? requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "") : "";
|
||||||
const couchdbUser = remoteType === "COUCHDB" ? requireEnv(env, "username") : "";
|
const couchdbUser = remoteType === "COUCHDB" ? requireEnv("COUCHDB_USER", "username") : "";
|
||||||
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv(env, "password") : "";
|
const couchdbPassword = remoteType === "COUCHDB" ? requireEnv("COUCHDB_PASSWORD", "password") : "";
|
||||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv(env, "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(env, "minioEndpoint").replace(/\/$/, "") : "";
|
const minioEndpoint =
|
||||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv(env, "accessKey") : "";
|
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv(env, "secretKey") : "";
|
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv(env, "bucketName") : "";
|
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||||
|
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||||
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
const minioBucket = remoteType === "MINIO" ? `${minioBucketBase}-${dbSuffix}` : "";
|
||||||
|
|
||||||
const passphrase = "e2e-passphrase";
|
const passphrase = "e2e-passphrase";
|
||||||
|
|||||||
@@ -6,30 +6,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert, assertStringIncludes } from "@std/assert";
|
import { assert, assertStringIncludes } from "@std/assert";
|
||||||
import { join } from "@std/path";
|
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { runCli } from "./helpers/cli.ts";
|
import { runCli } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
import { createCouchdbDatabase, startCouchdb, stopCouchdb, updateCouchdbDoc } from "./helpers/docker.ts";
|
||||||
|
|
||||||
const TEST_ENV = join(import.meta.dirname!, "..", ".test.env");
|
|
||||||
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
const MILESTONE_DOC = "_local/obsydian_livesync_milestone";
|
||||||
|
|
||||||
function requireEnv(env: Record<string, string>, key: string): string {
|
function requireEnv(...keys: string[]): string {
|
||||||
const value = env[key]?.trim();
|
for (const key of keys) {
|
||||||
if (!value) {
|
const value = Deno.env.get(key)?.trim();
|
||||||
throw new Error(`Required env var is missing: ${key}`);
|
if (value) return value;
|
||||||
}
|
}
|
||||||
return value;
|
throw new Error(`Required env var is missing: ${keys.join(" or ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.test("sync: actionable error against locked remote DB", async () => {
|
Deno.test("sync: actionable error against locked remote DB", async () => {
|
||||||
const env = await loadEnvFile(TEST_ENV);
|
const couchdbUri = requireEnv("COUCHDB_URI", "hostname").replace(/\/$/, "");
|
||||||
const couchdbUri = requireEnv(env, "hostname").replace(/\/$/, "");
|
const couchdbUser = requireEnv("COUCHDB_USER", "username");
|
||||||
const couchdbUser = requireEnv(env, "username");
|
const couchdbPassword = requireEnv("COUCHDB_PASSWORD", "password");
|
||||||
const couchdbPassword = requireEnv(env, "password");
|
const dbPrefix = requireEnv("COUCHDB_DBNAME", "dbname");
|
||||||
const dbPrefix = requireEnv(env, "dbname");
|
|
||||||
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
const dbname = `${dbPrefix}-locked-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||||
|
|
||||||
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
await using workDir = await TempDir.create("livesync-cli-locked-test");
|
||||||
|
|||||||
@@ -23,13 +23,11 @@
|
|||||||
* deno test -A test-sync-two-local-databases.ts
|
* deno test -A test-sync-two-local-databases.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { join } from "@std/path";
|
|
||||||
import { assertEquals, assert } from "@std/assert";
|
import { assertEquals, assert } from "@std/assert";
|
||||||
import { TempDir } from "./helpers/temp.ts";
|
import { TempDir } from "./helpers/temp.ts";
|
||||||
import { CLI_DIR, runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
import { runCliOrFail, jsonFieldIsNa } from "./helpers/cli.ts";
|
||||||
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
import { applyCouchdbSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||||
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
import { startCouchdb, stopCouchdb } from "./helpers/docker.ts";
|
||||||
import { loadEnvFile } from "./helpers/env.ts";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Load configuration
|
// Load configuration
|
||||||
@@ -41,20 +39,7 @@ async function resolveConfig(): Promise<{
|
|||||||
password: string;
|
password: string;
|
||||||
baseDbname: string;
|
baseDbname: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let env: Record<string, string> = {};
|
const env = Deno.env.toObject();
|
||||||
|
|
||||||
// 1. Explicit environment variables take priority
|
|
||||||
if (Deno.env.get("COUCHDB_URI")) {
|
|
||||||
env = Object.fromEntries(Deno.env.toObject());
|
|
||||||
} else {
|
|
||||||
// 2. TEST_ENV_FILE env var
|
|
||||||
const envFile = Deno.env.get("TEST_ENV_FILE") ?? join(CLI_DIR, ".test.env");
|
|
||||||
try {
|
|
||||||
env = await loadEnvFile(envFile);
|
|
||||||
} catch {
|
|
||||||
return null; // no config available — skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
const uri = (env["COUCHDB_URI"] ?? env["hostname"] ?? "").replace(/\/$/, "");
|
||||||
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
const user = env["COUCHDB_USER"] ?? env["username"] ?? "";
|
||||||
|
|||||||
@@ -11,11 +11,55 @@ const defaultExternal = [
|
|||||||
"crypto",
|
"crypto",
|
||||||
"pouchdb-adapter-leveldb",
|
"pouchdb-adapter-leveldb",
|
||||||
"commander",
|
"commander",
|
||||||
|
"chokidar",
|
||||||
"punycode",
|
"punycode",
|
||||||
"werift",
|
"werift",
|
||||||
];
|
];
|
||||||
|
// Polyfill FileReader at the very top of the CJS bundle. octagonal-wheels uses
|
||||||
|
// FileReader for base64 conversion when Uint8Array.toBase64 (TC39 proposal) is
|
||||||
|
// unavailable. Node.js has neither, so we inject a minimal FileReader shim before
|
||||||
|
// any module-scope code evaluates.
|
||||||
|
const fileReaderPolyfillBanner = `
|
||||||
|
if (typeof globalThis.FileReader === "undefined") {
|
||||||
|
globalThis.FileReader = class FileReader {
|
||||||
|
constructor() { this.result = null; this.onload = null; this.onerror = null; }
|
||||||
|
readAsDataURL(blob) {
|
||||||
|
blob.arrayBuffer().then((buf) => {
|
||||||
|
var b64 = require("buffer").Buffer.from(buf).toString("base64");
|
||||||
|
this.result = "data:" + (blob.type || "application/octet-stream") + ";base64," + b64;
|
||||||
|
if (this.onload) this.onload({ target: this });
|
||||||
|
}).catch((err) => { if (this.onerror) this.onerror({ target: this, error: err }); });
|
||||||
|
}
|
||||||
|
readAsArrayBuffer() { throw new Error("FileReader.readAsArrayBuffer is not implemented in this polyfill"); }
|
||||||
|
readAsBinaryString() { throw new Error("FileReader.readAsBinaryString is not implemented in this polyfill"); }
|
||||||
|
readAsText() { throw new Error("FileReader.readAsText is not implemented in this polyfill"); }
|
||||||
|
abort() { throw new Error("FileReader.abort is not implemented in this polyfill"); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectBanner(): import("vite").Plugin {
|
||||||
|
return {
|
||||||
|
name: "inject-banner",
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
for (const chunk of Object.values(bundle)) {
|
||||||
|
if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) {
|
||||||
|
// Insert after the shebang line if present, otherwise at the top.
|
||||||
|
if (chunk.code.startsWith("#!")) {
|
||||||
|
const newline = chunk.code.indexOf("\n");
|
||||||
|
chunk.code =
|
||||||
|
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||||
|
} else {
|
||||||
|
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte(), injectBanner()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
313
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal file
313
src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte
Normal 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>
|
||||||
131
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal file
131
src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts
Normal 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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
224
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
224
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal 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>
|
||||||
877
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
877
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
@@ -0,0 +1,877 @@
|
|||||||
|
<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;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: nowrap;
|
||||||
|
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: 14rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
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;
|
||||||
|
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>
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/lib
2
src/lib
Submodule src/lib updated: 97530553a6...a0af792b48
17
src/main.ts
17
src/main.ts
@@ -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);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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";
|
||||||
|
|
||||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||||
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||||
@@ -186,6 +187,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);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -66,6 +66,11 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
currentDeleted = false;
|
currentDeleted = false;
|
||||||
initialRev?: string;
|
initialRev?: string;
|
||||||
|
|
||||||
|
// Diff navigation state
|
||||||
|
currentDiffIndex = -1;
|
||||||
|
diffNavContainer!: HTMLDivElement;
|
||||||
|
diffNavIndicator!: HTMLSpanElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
app: App,
|
app: App,
|
||||||
core: LiveSyncBaseCore,
|
core: LiveSyncBaseCore,
|
||||||
@@ -140,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) {
|
||||||
@@ -163,58 +212,112 @@ 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 =
|
// Reset diff navigation after content changes
|
||||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
this.resetDiffNavigation();
|
||||||
|
if (this.showDiff) {
|
||||||
|
this.navigateDiff("next");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the previous or next diff block in the content view.
|
||||||
|
* Only effective when diff highlighting is enabled.
|
||||||
|
*/
|
||||||
|
navigateDiff(direction: "prev" | "next") {
|
||||||
|
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||||
|
if (diffElements.length === 0) return;
|
||||||
|
|
||||||
|
// Remove previous focus highlight
|
||||||
|
const prevFocused = this.contentView.querySelector(".diff-focused");
|
||||||
|
if (prevFocused) {
|
||||||
|
prevFocused.classList.remove("diff-focused");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === "next") {
|
||||||
|
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||||
|
} else {
|
||||||
|
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = diffElements[this.currentDiffIndex];
|
||||||
|
target.classList.add("diff-focused");
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
|
this.diffNavIndicator.textContent = `${this.currentDiffIndex + 1}/${diffElements.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the diff navigation index and update the indicator.
|
||||||
|
*/
|
||||||
|
resetDiffNavigation() {
|
||||||
|
this.currentDiffIndex = -1;
|
||||||
|
if (this.diffNavIndicator) {
|
||||||
|
if (this.showDiff) {
|
||||||
|
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
|
||||||
|
this.diffNavIndicator.textContent = diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014";
|
||||||
|
} else {
|
||||||
|
this.diffNavIndicator.textContent = "\u2014";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateDiffNavVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the diff navigation buttons based on the showDiff state.
|
||||||
|
*/
|
||||||
|
updateDiffNavVisibility() {
|
||||||
|
if (this.diffNavContainer) {
|
||||||
|
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +339,11 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
contentEl
|
const diffOptionsRow = contentEl.createDiv("");
|
||||||
.createDiv("", (e) => {
|
diffOptionsRow.addClass("op-info");
|
||||||
e.createEl("label", {}, (label) => {
|
diffOptionsRow.addClass("diff-options-row");
|
||||||
|
|
||||||
|
diffOptionsRow.createEl("label", {}, (label) => {
|
||||||
label.appendChild(
|
label.appendChild(
|
||||||
createEl("input", { type: "checkbox" }, (checkbox) => {
|
createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||||
if (this.showDiff) {
|
if (this.showDiff) {
|
||||||
@@ -247,14 +352,34 @@ export class DocumentHistoryModal extends Modal {
|
|||||||
checkbox.addEventListener("input", (evt: any) => {
|
checkbox.addEventListener("input", (evt: any) => {
|
||||||
this.showDiff = checkbox.checked;
|
this.showDiff = checkbox.checked;
|
||||||
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
||||||
|
this.updateDiffNavVisibility();
|
||||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
label.appendText("Highlight diff");
|
label.appendText("Highlight diff");
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.addClass("op-info");
|
// Diff navigation buttons
|
||||||
|
this.diffNavContainer = diffOptionsRow.createDiv("");
|
||||||
|
this.diffNavContainer.addClass("diff-nav");
|
||||||
|
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||||
|
|
||||||
|
this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => {
|
||||||
|
e.addClass("diff-nav-btn");
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.navigateDiff("prev");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.diffNavContainer.createEl("button", { text: "\u25BC Next" }, (e) => {
|
||||||
|
e.addClass("diff-nav-btn");
|
||||||
|
e.addEventListener("click", () => {
|
||||||
|
this.navigateDiff("next");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.diffNavIndicator = this.diffNavContainer.createEl("span", { text: "\u2014" });
|
||||||
|
this.diffNavIndicator.addClass("diff-nav-indicator");
|
||||||
|
|
||||||
this.info = contentEl.createDiv("");
|
this.info = contentEl.createDiv("");
|
||||||
this.info.addClass("op-info");
|
this.info.addClass("op-info");
|
||||||
fireAndForget(async () => await this.loadFile(this.initialRev));
|
fireAndForget(async () => await this.loadFile(this.initialRev));
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types.ts";
|
|
||||||
import { configURIBase } from "../../common/types.ts";
|
|
||||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
|
||||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
|
||||||
import {
|
|
||||||
EVENT_REQUEST_COPY_SETUP_URI,
|
|
||||||
EVENT_REQUEST_OPEN_P2P_SETTINGS,
|
|
||||||
EVENT_REQUEST_OPEN_SETUP_URI,
|
|
||||||
EVENT_REQUEST_SHOW_SETUP_QR,
|
|
||||||
eventHub,
|
|
||||||
} from "../../common/events.ts";
|
|
||||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
|
||||||
// import { performDoctorConsultation, RebuildOptions } from "@/lib/src/common/configForDoc.ts";
|
|
||||||
import type { LiveSyncCore } from "../../main.ts";
|
|
||||||
import {
|
|
||||||
encodeQR,
|
|
||||||
encodeSettingsToQRCodeData,
|
|
||||||
encodeSettingsToSetupURI,
|
|
||||||
OutputFormat,
|
|
||||||
} from "../../lib/src/API/processSetting.ts";
|
|
||||||
import { SetupManager, UserMode } from "./SetupManager.ts";
|
|
||||||
import { AbstractModule } from "../AbstractModule.ts";
|
|
||||||
|
|
||||||
export class ModuleSetupObsidian extends AbstractModule {
|
|
||||||
private _setupManager!: SetupManager;
|
|
||||||
private _everyOnload(): Promise<boolean> {
|
|
||||||
this._setupManager = this.core.getModule(SetupManager);
|
|
||||||
try {
|
|
||||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
|
||||||
if (conf.settings) {
|
|
||||||
await this._setupManager.onUseSetupURI(
|
|
||||||
UserMode.Unknown,
|
|
||||||
`${configURIBase}${encodeURIComponent(conf.settings)}`
|
|
||||||
);
|
|
||||||
} else if (conf.settingsQR) {
|
|
||||||
await this._setupManager.decodeQR(conf.settingsQR);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
this._log(
|
|
||||||
"Failed to register protocol handler. This feature may not work in some environments.",
|
|
||||||
LOG_LEVEL_NOTICE
|
|
||||||
);
|
|
||||||
this._log(e, LOG_LEVEL_VERBOSE);
|
|
||||||
}
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-setting-qr",
|
|
||||||
name: "Show settings as a QR code",
|
|
||||||
callback: () => fireAndForget(this.encodeQR()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupuri",
|
|
||||||
name: "Copy settings as a new setup URI",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURI()),
|
|
||||||
});
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupuri-short",
|
|
||||||
name: "Copy settings as a new setup URI (With customization sync)",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURIWithSync()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-copysetupurifull",
|
|
||||||
name: "Copy settings as a new setup URI (Full)",
|
|
||||||
callback: () => fireAndForget(this.command_copySetupURIFull()),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.addCommand({
|
|
||||||
id: "livesync-opensetupuri",
|
|
||||||
name: "Use the copied setup URI (Formerly Open setup URI)",
|
|
||||||
callback: () => fireAndForget(this.command_openSetupURI()),
|
|
||||||
});
|
|
||||||
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => this.command_openSetupURI()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => this.encodeQR()));
|
|
||||||
eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () =>
|
|
||||||
fireAndForget(() => {
|
|
||||||
return this._setupManager.onP2PManualSetup(UserMode.Update, this.settings, false);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
async encodeQR() {
|
|
||||||
const settingString = encodeSettingsToQRCodeData(this.settings);
|
|
||||||
const codeSVG = encodeQR(settingString, OutputFormat.SVG);
|
|
||||||
if (codeSVG == "") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const msg = $msg("Setup.QRCode", { qr_image: codeSVG });
|
|
||||||
await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK");
|
|
||||||
return await Promise.resolve(codeSVG);
|
|
||||||
}
|
|
||||||
|
|
||||||
async askEncryptingPassphrase(): Promise<string | false> {
|
|
||||||
const encryptingPassphrase = await this.core.confirm.askString(
|
|
||||||
"Encrypt your settings",
|
|
||||||
"The passphrase to encrypt the setup URI",
|
|
||||||
"",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
return encryptingPassphrase;
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURI(stripExtra = true) {
|
|
||||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
|
||||||
if (encryptingPassphrase === false) return;
|
|
||||||
const encryptedURI = await encodeSettingsToSetupURI(
|
|
||||||
this.settings,
|
|
||||||
encryptingPassphrase,
|
|
||||||
[...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (await this.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) {
|
|
||||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
|
||||||
}
|
|
||||||
// await navigator.clipboard.writeText(encryptedURI);
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURIFull() {
|
|
||||||
const encryptingPassphrase = await this.askEncryptingPassphrase();
|
|
||||||
if (encryptingPassphrase === false) return;
|
|
||||||
const encryptedURI = await encodeSettingsToSetupURI(this.settings, encryptingPassphrase, [], false);
|
|
||||||
await navigator.clipboard.writeText(encryptedURI);
|
|
||||||
this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async command_copySetupURIWithSync() {
|
|
||||||
await this.command_copySetupURI(false);
|
|
||||||
}
|
|
||||||
async command_openSetupURI() {
|
|
||||||
await this._setupManager.onUseSetupURI(UserMode.Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Where to implement these?
|
|
||||||
|
|
||||||
// async askSyncWithRemoteConfig(tryingSettings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
|
||||||
// const buttons = {
|
|
||||||
// fetch: $msg("Setup.FetchRemoteConf.Buttons.Fetch"),
|
|
||||||
// no: $msg("Setup.FetchRemoteConf.Buttons.Skip"),
|
|
||||||
// } as const;
|
|
||||||
// const fetchRemoteConf = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
// $msg("Setup.FetchRemoteConf.Message"),
|
|
||||||
// Object.values(buttons),
|
|
||||||
// { defaultAction: buttons.fetch, timeout: 0, title: $msg("Setup.FetchRemoteConf.Title") }
|
|
||||||
// );
|
|
||||||
// if (fetchRemoteConf == buttons.no) {
|
|
||||||
// return tryingSettings;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
|
||||||
// const remoteConfig = await this.services.tweakValue.fetchRemotePreferred(newSettings);
|
|
||||||
// if (remoteConfig) {
|
|
||||||
// this._log("Remote configuration found.", LOG_LEVEL_NOTICE);
|
|
||||||
// const resultSettings = {
|
|
||||||
// ...DEFAULT_SETTINGS,
|
|
||||||
// ...tryingSettings,
|
|
||||||
// ...remoteConfig,
|
|
||||||
// } satisfies ObsidianLiveSyncSettings;
|
|
||||||
// return resultSettings;
|
|
||||||
// } else {
|
|
||||||
// this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
|
||||||
// return {
|
|
||||||
// ...DEFAULT_SETTINGS,
|
|
||||||
// ...tryingSettings,
|
|
||||||
// } satisfies ObsidianLiveSyncSettings;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// async askPerformDoctor(
|
|
||||||
// tryingSettings: ObsidianLiveSyncSettings
|
|
||||||
// ): Promise<{ settings: ObsidianLiveSyncSettings; shouldRebuild: boolean; isModified: boolean }> {
|
|
||||||
// const buttons = {
|
|
||||||
// yes: $msg("Setup.Doctor.Buttons.Yes"),
|
|
||||||
// no: $msg("Setup.Doctor.Buttons.No"),
|
|
||||||
// } as const;
|
|
||||||
// const performDoctor = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
// $msg("Setup.Doctor.Message"),
|
|
||||||
// Object.values(buttons),
|
|
||||||
// { defaultAction: buttons.yes, timeout: 0, title: $msg("Setup.Doctor.Title") }
|
|
||||||
// );
|
|
||||||
// if (performDoctor == buttons.no) {
|
|
||||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newSettings = JSON.parse(JSON.stringify(tryingSettings)) as ObsidianLiveSyncSettings;
|
|
||||||
// const { settings, shouldRebuild, isModified } = await performDoctorConsultation(this.core, newSettings, {
|
|
||||||
// localRebuild: RebuildOptions.AutomaticAcceptable, // Because we are in the setup wizard, we can skip the confirmation.
|
|
||||||
// remoteRebuild: RebuildOptions.SkipEvenIfRequired,
|
|
||||||
// activateReason: "New settings from URI",
|
|
||||||
// });
|
|
||||||
// if (isModified) {
|
|
||||||
// this._log("Doctor has fixed some issues!", LOG_LEVEL_NOTICE);
|
|
||||||
// return {
|
|
||||||
// settings,
|
|
||||||
// shouldRebuild,
|
|
||||||
// isModified,
|
|
||||||
// };
|
|
||||||
// } else {
|
|
||||||
// this._log("Doctor detected no issues!", LOG_LEVEL_NOTICE);
|
|
||||||
// return { settings: tryingSettings, shouldRebuild: false, isModified: false };
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
|
||||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -61,10 +61,12 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
|||||||
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
||||||
fireAndForget(async () => {
|
fireAndForget(async () => {
|
||||||
try {
|
try {
|
||||||
await this.core.services.control.applySettings();
|
const lang = this.core.services.setting.currentSettings()?.displayLanguage;
|
||||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage ?? undefined;
|
|
||||||
if (lang !== undefined) {
|
if (lang !== undefined) {
|
||||||
setLang(this.core.services.setting.currentSettings()?.displayLanguage);
|
setLang(lang);
|
||||||
|
}
|
||||||
|
if (this.core.services.database.isDatabaseReady()) {
|
||||||
|
await this.core.services.control.applySettings();
|
||||||
}
|
}
|
||||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
styles.css
41
styles.css
@@ -485,3 +485,44 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Diff navigation */
|
||||||
|
.diff-options-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-btn:hover {
|
||||||
|
background-color: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-nav-indicator {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 3em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-focused {
|
||||||
|
outline: 2px solid var(--interactive-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
261
updates.md
261
updates.md
@@ -3,6 +3,102 @@ 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
|
## 0.25.60
|
||||||
|
|
||||||
29th April, 2026
|
29th April, 2026
|
||||||
@@ -113,170 +209,5 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
|
|||||||
- 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 didn’t. 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).
|
||||||
|
|||||||
166
updates_old.md
166
updates_old.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal 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));
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
30
vitest.config.rpc-unit.ts
Normal file
30
vitest.config.rpc-unit.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig, mergeConfig } from "vitest/config";
|
||||||
|
import viteConfig from "./vitest.config.common";
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
obsidian: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
name: "rpc-unit-tests",
|
||||||
|
include: ["src/lib/src/rpc/**/*.unit.spec.ts"],
|
||||||
|
exclude: ["test/**"],
|
||||||
|
coverage: {
|
||||||
|
include: ["src/lib/src/rpc/**/*.ts"],
|
||||||
|
exclude: ["**/*.unit.spec.ts", "**/index.ts"],
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html", ["text", { file: "coverage-rpc-text.txt" }]],
|
||||||
|
thresholds: {
|
||||||
|
lines: 90,
|
||||||
|
functions: 90,
|
||||||
|
branches: 75,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user