mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-01 16:12:08 +03:00
Compare commits
125 Commits
update_lib
...
fix_930
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
674d68b7d9 | ||
|
|
8171db353a | ||
|
|
6ab1556880 | ||
|
|
cd2bff5fc7 | ||
|
|
c6697327d5 | ||
|
|
7c203a522a | ||
|
|
c80c294d93 | ||
|
|
3e65ae932d | ||
|
|
f710f03380 | ||
|
|
b887269fc1 | ||
|
|
56a234e6d7 | ||
|
|
39014b2294 | ||
|
|
24e6c110a3 | ||
|
|
f24d110552 | ||
|
|
7189c1c05a | ||
|
|
547afe9a86 | ||
|
|
7b5876037d | ||
|
|
b714c00644 | ||
|
|
e14e771bfb | ||
|
|
1130bbcee8 | ||
|
|
8841ef4619 | ||
|
|
45fe0b3682 | ||
|
|
8d3825abc9 | ||
|
|
c5f9841b85 | ||
|
|
d36d176d99 | ||
|
|
38b2cf73ed | ||
|
|
40b15a6950 | ||
|
|
e312bb7640 | ||
|
|
852c0e6c13 | ||
|
|
7c1bcf9e9b | ||
|
|
2b79bed085 | ||
|
|
6b1e0c4aa8 | ||
|
|
3c3645eba4 | ||
|
|
009cc3c87a | ||
|
|
fc5fd4be94 | ||
|
|
8ed1acf79d | ||
|
|
c518223d21 | ||
|
|
caaff618e9 | ||
|
|
148aa8505e | ||
|
|
f9a626a858 | ||
|
|
1b8747115c | ||
|
|
e739302fb9 | ||
|
|
8f20d53f55 | ||
|
|
fd84b0377b | ||
|
|
340d416b76 | ||
|
|
3034af8d69 | ||
|
|
da3020bd45 | ||
|
|
ce232c1002 | ||
|
|
0e13926400 | ||
|
|
fab7ec996a | ||
|
|
88e22f99c5 | ||
|
|
83cbabf06f | ||
|
|
5e8d3b8f02 | ||
|
|
1167b41340 | ||
|
|
67da3964e5 | ||
|
|
45ebc7eb6b | ||
|
|
cc5ead68bc | ||
|
|
9b9e4f22f3 | ||
|
|
7823f46053 | ||
|
|
d6d8e548b3 | ||
|
|
44b1ed7610 | ||
|
|
5786da5534 | ||
|
|
042a80dd44 | ||
|
|
ee30f6cd6d | ||
|
|
977a300808 | ||
|
|
a392ccab6a | ||
|
|
dfdfa5383b | ||
|
|
a08294ab16 | ||
|
|
e8c33a0d6a | ||
|
|
d6bf453a6d | ||
|
|
e80cdc2dae | ||
|
|
60780678fd | ||
|
|
273e7a2b63 | ||
|
|
2572c54744 | ||
|
|
6a7c987985 | ||
|
|
6ef866a77c | ||
|
|
eea26dee74 | ||
|
|
ee24fe8c24 | ||
|
|
9d9364af36 | ||
|
|
83228e2077 | ||
|
|
a379b5bd78 | ||
|
|
4ed1749652 | ||
|
|
9a90256a8a | ||
|
|
f0628a0d2c | ||
|
|
d5e2f57781 | ||
|
|
02673a1631 | ||
|
|
91c9746886 | ||
|
|
75b44b1636 | ||
|
|
f1fe48c1ee | ||
|
|
437e7c0d9c | ||
|
|
5ffa7ec7ee | ||
|
|
a1859f5d2e | ||
|
|
785af8cb8f | ||
|
|
06e1f4aa4a | ||
|
|
767f22ce9c | ||
|
|
6a9bba702c | ||
|
|
de2397dc3f | ||
|
|
daaad9212e | ||
|
|
a6891374a1 | ||
|
|
b1cadf0549 | ||
|
|
95f40cc954 | ||
|
|
8deaf123d6 | ||
|
|
053813bffb | ||
|
|
cc7af03618 | ||
|
|
a130e3700e | ||
|
|
0549e901b2 | ||
|
|
e9afe06968 | ||
|
|
37715d4c9f | ||
|
|
106367fa41 | ||
|
|
538130aa91 | ||
|
|
c9d0357fec | ||
|
|
d05c76da36 | ||
|
|
d2eb6ecbaf | ||
|
|
25a6fde212 | ||
|
|
e8f8b680ef | ||
|
|
6c30f2b863 | ||
|
|
770d4af4a0 | ||
|
|
3b311248cb | ||
|
|
5772811a45 | ||
|
|
55529cd71e | ||
|
|
2e9b8b7b62 | ||
|
|
5454e1106f | ||
|
|
0d9397c8b9 | ||
|
|
429a3ff1fd | ||
|
|
bfff6ea7b8 |
31
.github/ISSUE_TEMPLATE/issue-report.md
vendored
31
.github/ISSUE_TEMPLATE/issue-report.md
vendored
@@ -53,11 +53,12 @@ The hatch report (below) includes version information. If you cannot provide the
|
||||
|
||||
- Self-hosted LiveSync version: <!-- e.g. 0.23.0 — find it in Obsidian Settings → Community Plugins -->
|
||||
|
||||
### Report from LiveSync
|
||||
Open the `Hatch` pane in LiveSync settings and press `Make report`. Paste here or upload to [Gist](https://gist.github.com/) and share the link.
|
||||
### Report and Logs from LiveSync
|
||||
Perform a `Generate full report for opening the issue with debug info` command and provide the generated report. This contains detailed information and recent 1000 log lines, which is very helpful for debugging. **PLEASE AMEND THE REPORT TO REMOVE ANY SENSITIVE INFORMATION BEFORE PASTING.**
|
||||
If too large to paste here, upload to [Gist](https://gist.github.com/) and share the link.
|
||||
|
||||
<details>
|
||||
<summary>Report from hatch (primary)</summary>
|
||||
<summary>Report and Logs (primary)</summary>
|
||||
|
||||
```
|
||||
<!-- paste here or link to Gist -->
|
||||
@@ -65,29 +66,7 @@ Open the `Hatch` pane in LiveSync settings and press `Make report`. Paste here o
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Report from hatch (if applicable)</summary>
|
||||
|
||||
```
|
||||
<!-- paste here or link to Gist -->
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
### Plug-in log
|
||||
Enable `Verbose Log` in General Settings first, then reproduce the issue and copy the log (tap the document box icon in the ribbon).
|
||||
Paste here or upload to [Gist](https://gist.github.com/) and share the link.
|
||||
|
||||
<details>
|
||||
<summary>Plug-in log (primary)</summary>
|
||||
|
||||
```
|
||||
<!-- paste here or link to Gist -->
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>Plug-in log (if applicable)</summary>
|
||||
<summary>Report and Logs (if applicable)</summary>
|
||||
|
||||
```
|
||||
<!-- paste here or link to Gist -->
|
||||
|
||||
68
.github/workflows/release.yml
vendored
68
.github/workflows/release.yml
vendored
@@ -9,6 +9,10 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -29,68 +33,20 @@ jobs:
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
# Attest
|
||||
- name: Attest Plugin Artifacts
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
# Package the required files into a zip
|
||||
- name: Package
|
||||
run: |
|
||||
mkdir ${{ github.event.repository.name }}
|
||||
cp main.js manifest.json styles.css README.md ${{ github.event.repository.name }}
|
||||
zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }}
|
||||
# Create the release on github
|
||||
# - name: Create Release
|
||||
# id: create_release
|
||||
# uses: actions/create-release@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# VERSION: ${{ steps.version.outputs.tag }}
|
||||
# with:
|
||||
# tag_name: ${{ steps.version.outputs.tag }}
|
||||
# release_name: ${{ steps.version.outputs.tag }}
|
||||
# draft: true
|
||||
# prerelease: false
|
||||
# # Upload the packaged release file
|
||||
# - name: Upload zip file
|
||||
# id: upload-zip
|
||||
# uses: actions/upload-release-asset@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# with:
|
||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
# asset_path: ./${{ github.event.repository.name }}.zip
|
||||
# asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip
|
||||
# asset_content_type: application/zip
|
||||
# # Upload the main.js
|
||||
# - name: Upload main.js
|
||||
# id: upload-main
|
||||
# uses: actions/upload-release-asset@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# with:
|
||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
# asset_path: ./main.js
|
||||
# asset_name: main.js
|
||||
# asset_content_type: text/javascript
|
||||
# # Upload the manifest.json
|
||||
# - name: Upload manifest.json
|
||||
# id: upload-manifest
|
||||
# uses: actions/upload-release-asset@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# with:
|
||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
# asset_path: ./manifest.json
|
||||
# asset_name: manifest.json
|
||||
# asset_content_type: application/json
|
||||
# # Upload the style.css
|
||||
# - name: Upload styles.css
|
||||
# id: upload-css
|
||||
# uses: actions/upload-release-asset@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# with:
|
||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
# asset_path: ./styles.css
|
||||
# asset_name: styles.css
|
||||
# asset_content_type: text/css
|
||||
- name: Create Release and Upload Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
||||
13
.github/workflows/unit-ci.yml
vendored
13
.github/workflows/unit-ci.yml
vendored
@@ -10,7 +10,18 @@ on:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'test/**'
|
||||
- 'lib/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
- 'vite.config.ts'
|
||||
- 'vitest.config*.ts'
|
||||
- 'esbuild.config.mjs'
|
||||
- 'eslint.config.mjs'
|
||||
- '.github/workflows/unit-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'test/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'tsconfig.json'
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -28,4 +28,7 @@ data.json
|
||||
cov_profile/**
|
||||
|
||||
coverage
|
||||
src/apps/cli/dist/*
|
||||
src/apps/cli/dist/*
|
||||
_testdata/**
|
||||
utils/bench/splitResults.csv
|
||||
.eslintcache
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
|
||||
2
devs.md
2
devs.md
@@ -17,7 +17,7 @@ The plugin uses a dynamic module system to reduce coupling and improve maintaina
|
||||
- `coreObsidian/` - Obsidian-specific core (e.g., `ModuleFileAccessObsidian`)
|
||||
- `essential/` - Required modules (e.g., `ModuleMigration`, `ModuleKeyValueDB`)
|
||||
- `features/` - Optional features (e.g., `ModuleLog`, `ModuleObsidianSettings`)
|
||||
- `extras/` - Development/testing tools (e.g., `ModuleDev`, `ModuleIntegratedTest`)
|
||||
- `extras/` - Development/testing tools (e.g., `ModuleDev`, ~~`ModuleIntegratedTest`~~)
|
||||
- **Services**: Core services (e.g., `database`, `replicator`, `storageAccess`) are registered in `ServiceHub` and accessed by modules. They provide an extension point for add new behaviour without modifying existing code.
|
||||
- For example, checks before the replication can be added to the `replication.onBeforeReplicate` handler, and the handlers can be return `false` to prevent replication-starting. `vault.isTargetFile` also can be used to prevent processing specific files.
|
||||
- **ServiceModule**: A new type of module that directly depends on services.
|
||||
|
||||
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.
|
||||
|
||||
@@ -255,14 +255,20 @@ It depends on Obsidian detects. May toggling `Detect all extensions` of
|
||||
|
||||
### I hope to report the issue, but you said you needs `Report`. How to make it?
|
||||
|
||||
We can copy the report to the clipboard, by pressing the `Make report` button on
|
||||
the `Hatch` pane. 
|
||||
We can copy the report to the clipboard, by performing
|
||||
`Generate full report for opening the issue with debug info` command!
|
||||
|
||||
### Where can I check the log?
|
||||
|
||||
We can launch the log pane by `Show log` on the command palette. And if you have
|
||||
troubled something, please enable the `Verbose Log` on the `General Setting`
|
||||
pane.
|
||||
`Generate full report for opening the issue with debug info` command also contains
|
||||
the recent 1000 log lines, which is very helpful for debugging. Full-report is
|
||||
already set to the verbose level, so it contains all the logs without enabling the
|
||||
`Verbose Log` toggle.
|
||||
|
||||
Let me note that please be sure to remove any sensitive information before sharing the report.
|
||||
|
||||
However, the logs would not be kept so long and cleared when restarted. If you
|
||||
want to check the logs, please enable `Write logs into the file` temporarily.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import sveltePlugin from "esbuild-svelte";
|
||||
import { sveltePreprocess } from "svelte-preprocess";
|
||||
import fs from "node:fs";
|
||||
|
||||
@@ -1,103 +1,134 @@
|
||||
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 path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import * as sveltePlugin from "eslint-plugin-svelte";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
const warnWhileDev = "off"; // Change to "warn" to enable warnings for rules that are currently disabled.
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
// Build outputs and legacy files
|
||||
"**/build",
|
||||
"coverage",
|
||||
"**/main.js",
|
||||
"main_org.js",
|
||||
"pouchdb-browser.js",
|
||||
"version-bump.mjs",
|
||||
"package.json",
|
||||
"**/*.json",
|
||||
"**/.eslintrc.js.bak",
|
||||
// Files from linked dependencies (those files should not exist for most people).
|
||||
"modules/octagonal-wheels/dist/**/*",
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
// Sub-projects (Exclude from root linting as they have different environments)
|
||||
"src/apps/**/*",
|
||||
"utils/**/*",
|
||||
|
||||
export default [
|
||||
// Specific exclusions from common library (src/lib)
|
||||
"src/lib/coverage",
|
||||
"src/lib/browsertest",
|
||||
"src/lib/test",
|
||||
"src/lib/_tools",
|
||||
"src/lib/src/patches/pouchdb-utils",
|
||||
"src/lib/src/cli",
|
||||
"src/lib/src/services/implements/browser/**",
|
||||
"src/lib/src/services/implements/headless/**",
|
||||
"src/lib/src/API",
|
||||
|
||||
// Config files and build scripts
|
||||
"**/jest.config.js",
|
||||
"**/rollup.config.js",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/terser.*.mjs",
|
||||
".prettierrc.*.mjs",
|
||||
".prettierrc.mjs",
|
||||
"*.config.mjs",
|
||||
"vite.*",
|
||||
"vitest.*",
|
||||
// Testing files (Simplified patterns)
|
||||
"test/**",
|
||||
"**/*.test.ts",
|
||||
"**/*.unit.spec.ts",
|
||||
"**/test.ts",
|
||||
"**/tests.ts",
|
||||
]),
|
||||
...sveltePlugin.configs["flat/base"],
|
||||
...obsidianmd.configs.recommended,
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules/*",
|
||||
"**/jest.config.js",
|
||||
"src/lib/coverage",
|
||||
"src/lib/browsertest",
|
||||
"**/test.ts",
|
||||
"**/tests.ts",
|
||||
"**/**test.ts",
|
||||
"**/**.test.ts",
|
||||
"**/esbuild.*.mjs",
|
||||
"**/terser.*.mjs",
|
||||
"**/node_modules",
|
||||
"**/build",
|
||||
"**/.eslintrc.js.bak",
|
||||
"src/lib/src/patches/pouchdb-utils",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/rollup.config.js",
|
||||
"modules/octagonal-wheels/rollup.config.js",
|
||||
"modules/octagonal-wheels/dist/**/*",
|
||||
"src/lib/test",
|
||||
"src/lib/_tools",
|
||||
"src/lib/src/cli",
|
||||
"**/main.js",
|
||||
"src/apps/**/*",
|
||||
".prettierrc.*.mjs",
|
||||
".prettierrc.mjs",
|
||||
"*.config.mjs"
|
||||
],
|
||||
},
|
||||
...compat.extends(
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
),
|
||||
{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
svelte,
|
||||
import: fixupPluginRules(_import),
|
||||
},
|
||||
|
||||
files: ["**/*.ts"],
|
||||
// ignores:["src/lib/**/*.ts"], // Exclude library files from root linting (they have different environments and rules).
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, PouchDB: "readonly" },
|
||||
parser: tsParser,
|
||||
ecmaVersion: 5,
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: ["tsconfig.json"],
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
|
||||
linterOptions:{
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
rules: {
|
||||
// -- Base rules (turned off in favour of TS specific versions or explicitly disabled).
|
||||
"no-unused-vars": "off",
|
||||
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "none",
|
||||
},
|
||||
],
|
||||
|
||||
"no-unused-labels": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"require-await": "error",
|
||||
"@typescript-eslint/require-await": "warn",
|
||||
"@typescript-eslint/no-misused-promises": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "warn",
|
||||
"no-async-promise-executor": "warn",
|
||||
"require-await": "off",
|
||||
// -- TypeScript specific rules
|
||||
// @typescript-eslint/no-unsafe-* rules and @typescript-eslint/no-explicit-any:
|
||||
// This project contains a lot of library-sh code where the use of `any` is often necessary and justified.
|
||||
// Rules is now set to 'off' for a while.
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
// -- Reasonable rules.
|
||||
"@typescript-eslint/no-deprecated": warnWhileDev,
|
||||
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/require-await": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
checkLoops: false,
|
||||
},
|
||||
],
|
||||
// -- Obsidian rules
|
||||
// obsidianmd/no-unsupported-api: usually this project checks for API support at runtime, so this rule is not critical but can be helpful to catch potential issues.
|
||||
"obsidianmd/no-unsupported-api": warnWhileDev,
|
||||
|
||||
// -- General rules
|
||||
"no-async-promise-executor": warnWhileDev,
|
||||
"no-constant-condition": ["error", { checkLoops: false }],
|
||||
// -- Disabled rules
|
||||
// no-undef: This option breaks the global declarations for the library files and is not worth the effort to fix at this time.
|
||||
"no-undef": "off",
|
||||
|
||||
// -- Plugin specific overrides
|
||||
"obsidianmd/rule-custom-message": "off",
|
||||
"obsidianmd/ui/sentence-case": "off",
|
||||
"obsidianmd/no-plugin-as-component": "off",
|
||||
|
||||
// -- Temporary overrides for migration
|
||||
"obsidianmd/no-static-styles-assignment": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
extraFileExtensions: [".svelte"],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// no-unused-vars:
|
||||
// Svelte template's declarations have a lot of false positives and the rule is not worth the effort to fix at this time.
|
||||
// it may improve in the future with some options as like ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],]
|
||||
"no-unused-vars": "off",
|
||||
"obsidianmd/no-plugin-as-component": "off",
|
||||
"obsidianmd/ui/sentence-case": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.60",
|
||||
"minAppVersion": "0.9.12",
|
||||
"version": "0.25.70-patch1",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
|
||||
1661
package-lock.json
generated
1661
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.60",
|
||||
"version": "0.25.70-patch1",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -19,13 +19,13 @@
|
||||
"buildVite": "npx dotenv-cli -e .env -- vite build --mode production",
|
||||
"buildViteOriginal": "npx dotenv-cli -e .env -- vite build --mode original",
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint src",
|
||||
"lint": "eslint --cache --concurrency auto src",
|
||||
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"tsc-check": "tsc --noEmit",
|
||||
"pretty": "npm run prettyNoWrite -- --write --log-level error",
|
||||
"prettyCheck": "npm run prettyNoWrite -- --check",
|
||||
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
|
||||
"check": "npm run lint && npm run svelte-check",
|
||||
"check": "npm run tsc-check && npm run lint && npm run svelte-check",
|
||||
"unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run --config vitest.config.unit.ts",
|
||||
@@ -54,15 +54,14 @@
|
||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||
"test:docker-all: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: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": [],
|
||||
"author": "vorotamoroz",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@chialab/esbuild-plugin-worker": "^0.19.0",
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
@@ -84,18 +83,15 @@
|
||||
"@vitest/browser": "^4.1.1",
|
||||
"@vitest/browser-playwright": "^4.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"builtin-modules": "5.0.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"esbuild-svelte": "^0.9.4",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"events": "^3.3.0",
|
||||
"glob": "^13.0.6",
|
||||
"obsidian": "^1.12.3",
|
||||
"globals": "^14.0.0",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
@@ -116,6 +112,7 @@
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -133,7 +130,8 @@
|
||||
"@smithy/middleware-apply-body-checksum": "^4.3.9",
|
||||
"@smithy/protocol-http": "^5.3.9",
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@trystero-p2p/nostr": "^0.23.0",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@trystero-p2p/nostr": "^0.24.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -142,10 +140,11 @@
|
||||
"markdown-it": "^14.1.1",
|
||||
"micromatch": "^4.0.0",
|
||||
"minimatch": "^10.2.2",
|
||||
"octagonal-wheels": "^0.1.45",
|
||||
"obsidian": "^1.12.3",
|
||||
"octagonal-wheels": "^0.1.46",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"werift": "^0.22.9",
|
||||
"werift": "^0.23.0",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
|
||||
import { __$checkInstanceBinding } from "./lib/src/dev/checks";
|
||||
@@ -123,7 +124,7 @@ export class LiveSyncBaseCore<
|
||||
for (const module of this.modules) {
|
||||
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);
|
||||
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
|
||||
} else {
|
||||
// module should not be never.
|
||||
const moduleName = (module as unknown)?.constructor?.name ?? "unknown";
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ RUN apt-get update \
|
||||
WORKDIR /build
|
||||
|
||||
# Install workspace dependencies first (layer-cache friendly)
|
||||
COPY package.json ./
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the full source tree and build the CLI bundle
|
||||
|
||||
@@ -74,6 +74,12 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||
- `cat <src>`: Read a file from the database and write to stdout.
|
||||
- `put <dst>`: Read from stdin and write to the database path `<dst>`.
|
||||
- `remote-add <name> <connstr>`: Add a remote configuration from a connection string.
|
||||
- `remote-rm <remote-id>`: Remove a remote configuration by ID.
|
||||
- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI).
|
||||
- `remote-export <remote-id>`: Export the stored connection string by remote ID.
|
||||
- `remote-set <remote-id> <connstr>`: Replace the stored connection string by remote ID.
|
||||
- `remote-activate <remote-id>`: Activate a remote configuration by ID.
|
||||
- `init-settings [file]`: Create a default settings file.
|
||||
|
||||
### Examples
|
||||
@@ -252,6 +258,14 @@ livesync-cli /path/to/your-local-database --settings /path/to/settings.json rm /
|
||||
|
||||
# Resolve conflict by keeping a specific revision
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef
|
||||
|
||||
# Add/list/activate/remove remote configurations
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-add main "sls+https://user:pass@example.com/db"
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-ls
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-export remote-abc123
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-set remote-abc123 "sls+p2p://room-abc?passphrase=secret"
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-activate remote-abc123
|
||||
livesync-cli /path/to/your-local-database --settings /path/to/settings.json remote-rm remote-abc123
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -257,12 +257,12 @@ describe("daemon command", () => {
|
||||
// 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)
|
||||
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) {
|
||||
|
||||
@@ -32,10 +32,15 @@ function validateP2PSettings(core: LiveSyncBaseCore<ServiceContext, any>) {
|
||||
settings.P2P_IsHeadless = true;
|
||||
}
|
||||
|
||||
function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): LiveSyncTrysteroReplicator {
|
||||
async function createReplicator(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
||||
validateP2PSettings(core);
|
||||
const replicator = new LiveSyncTrysteroReplicator({ services: core.services });
|
||||
addP2PEventHandlers(replicator);
|
||||
const replicator = await core.services.replicator.getNewReplicator();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,7 @@ export async function collectPeers(
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer[]> {
|
||||
const replicator = createReplicator(core);
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
try {
|
||||
await delay(timeoutSec * 1000);
|
||||
@@ -79,7 +84,7 @@ export async function syncWithPeer(
|
||||
peerToken: string,
|
||||
timeoutSec: number
|
||||
): Promise<CLIP2PPeer> {
|
||||
const replicator = createReplicator(core);
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
try {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
@@ -115,7 +120,7 @@ export async function syncWithPeer(
|
||||
}
|
||||
|
||||
export async function openP2PHost(core: LiveSyncBaseCore<ServiceContext, any>): Promise<LiveSyncTrysteroReplicator> {
|
||||
const replicator = createReplicator(core);
|
||||
const replicator = await createReplicator(core);
|
||||
await replicator.open();
|
||||
return replicator;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as path from "path";
|
||||
import { decodeSettingsFromSetupURI } from "@lib/API/processSetting";
|
||||
import { configURIBase } from "@lib/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import { activateRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
|
||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
@@ -10,6 +12,10 @@ import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./
|
||||
import { performFullScan } from "@lib/serviceFeatures/offlineScanner";
|
||||
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
|
||||
@@ -43,10 +49,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
// 3. Re-enable sync.
|
||||
const restoreSyncSettings = async () => {
|
||||
await core.services.setting.applyPartial({
|
||||
...context.originalSyncSettings,
|
||||
suspendFileWatching: false,
|
||||
}, true);
|
||||
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.
|
||||
@@ -54,10 +63,13 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
// 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);
|
||||
await core.services.setting.applyPartial(
|
||||
{
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
if (options.interval) {
|
||||
log(`Polling mode: syncing every ${options.interval}s`);
|
||||
@@ -80,7 +92,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
||||
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`);
|
||||
console.error(
|
||||
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
pollTimer = setTimeout(poll, currentIntervalMs);
|
||||
@@ -99,9 +113,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
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.");
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,5 +475,206 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return await performFullScan(core as any, log, errorManager, false, true);
|
||||
}
|
||||
|
||||
if (options.command === "remote-add") {
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("remote-add requires two arguments: <name> <connstr>");
|
||||
}
|
||||
const name = options.commandArgs[0].trim();
|
||||
const connectionString = options.commandArgs[1].trim();
|
||||
if (!name) {
|
||||
throw new Error("remote-add requires a non-empty name");
|
||||
}
|
||||
if (!connectionString) {
|
||||
throw new Error("remote-add requires a non-empty connection string");
|
||||
}
|
||||
|
||||
const parsed = ConnectionStringParser.parse(connectionString);
|
||||
const canonicalUri = ConnectionStringParser.serialize(parsed);
|
||||
const id = createRemoteConfigurationId();
|
||||
let activated = false;
|
||||
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
currentSettings.remoteConfigurations ||= {};
|
||||
currentSettings.remoteConfigurations[id] = {
|
||||
id,
|
||||
name,
|
||||
uri: canonicalUri,
|
||||
isEncrypted: false,
|
||||
};
|
||||
if (!currentSettings.activeConfigurationId) {
|
||||
currentSettings.activeConfigurationId = id;
|
||||
const applied = activateRemoteConfiguration(currentSettings, id);
|
||||
activated = applied !== false;
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (activated) {
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
process.stdout.write(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-rm") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("remote-rm requires one argument: <remote-id>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-rm requires a non-empty remote-id");
|
||||
}
|
||||
|
||||
const current = core.services.setting.currentSettings();
|
||||
if (!current.remoteConfigurations?.[id]) {
|
||||
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let switchedActive = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const configs = currentSettings.remoteConfigurations || {};
|
||||
delete configs[id];
|
||||
currentSettings.remoteConfigurations = configs;
|
||||
|
||||
if (currentSettings.activeConfigurationId === id) {
|
||||
const nextActiveId = Object.keys(configs)[0] || "";
|
||||
currentSettings.activeConfigurationId = nextActiveId;
|
||||
switchedActive = nextActiveId !== "";
|
||||
if (nextActiveId !== "") {
|
||||
activateRemoteConfiguration(currentSettings, nextActiveId);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSettings.P2P_ActiveRemoteConfigurationId === id) {
|
||||
currentSettings.P2P_ActiveRemoteConfigurationId = "";
|
||||
}
|
||||
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (switchedActive) {
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
console.error(`[Command] remote-rm ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-ls") {
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const configs = Object.values(settings.remoteConfigurations || {});
|
||||
configs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (configs.length === 0) {
|
||||
process.stderr.write("[Info] No remote configurations found.\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
const lines = configs.map((config) => {
|
||||
const status = config.id === settings.activeConfigurationId ? "active" : "inactive";
|
||||
return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`;
|
||||
});
|
||||
process.stdout.write(lines.join("\n") + "\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-export") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("remote-export requires one argument: <remote-id>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-export requires a non-empty remote-id");
|
||||
}
|
||||
|
||||
const config = core.services.setting.currentSettings().remoteConfigurations?.[id];
|
||||
if (!config) {
|
||||
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
process.stdout.write(`${config.uri}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-set") {
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("remote-set requires two arguments: <remote-id> <connstr>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
const connectionString = options.commandArgs[1].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-set requires a non-empty remote-id");
|
||||
}
|
||||
if (!connectionString) {
|
||||
throw new Error("remote-set requires a non-empty connection string");
|
||||
}
|
||||
|
||||
const parsed = ConnectionStringParser.parse(connectionString);
|
||||
const canonicalUri = ConnectionStringParser.serialize(parsed);
|
||||
let switchedActive = false;
|
||||
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const config = currentSettings.remoteConfigurations?.[id];
|
||||
if (!config) {
|
||||
return currentSettings;
|
||||
}
|
||||
config.uri = canonicalUri;
|
||||
|
||||
if (currentSettings.activeConfigurationId === id) {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
switchedActive = activated !== false;
|
||||
if (activated) {
|
||||
return activated;
|
||||
}
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
const updated = core.services.setting.currentSettings().remoteConfigurations?.[id];
|
||||
if (!updated) {
|
||||
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (switchedActive) {
|
||||
await core.services.control.applySettings();
|
||||
}
|
||||
|
||||
console.error(`[Command] remote-set ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "remote-activate") {
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("remote-activate requires one argument: <remote-id>");
|
||||
}
|
||||
const id = options.commandArgs[0].trim();
|
||||
if (!id) {
|
||||
throw new Error("remote-activate requires a non-empty remote-id");
|
||||
}
|
||||
|
||||
let switched = false;
|
||||
await core.services.setting.updateSettings((currentSettings) => {
|
||||
const activated = activateRemoteConfiguration(currentSettings, id);
|
||||
if (activated) {
|
||||
switched = true;
|
||||
return activated;
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (!switched) {
|
||||
process.stderr.write(`[Info] Failed to activate remote configuration: ${id}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await core.services.control.applySettings();
|
||||
console.error(`[Command] remote-activate ${id}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported command: ${options.command}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import * as processSetting from "@lib/API/processSetting";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import { configURIBase } from "@lib/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS } from "@lib/common/types";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@lib/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
import * as commandUtils from "./utils";
|
||||
|
||||
function createCoreMock() {
|
||||
const liveSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
P2P_ActiveRemoteConfigurationId: "",
|
||||
} as any;
|
||||
return {
|
||||
services: {
|
||||
control: {
|
||||
@@ -16,6 +23,10 @@ function createCoreMock() {
|
||||
setting: {
|
||||
applyExternalSettings: vi.fn(async () => {}),
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
currentSettings: vi.fn(() => liveSettings),
|
||||
updateSettings: vi.fn(async (updater: any) => {
|
||||
updater(liveSettings);
|
||||
}),
|
||||
},
|
||||
},
|
||||
serviceModules: {
|
||||
@@ -56,6 +67,115 @@ async function createSetupURI(passphrase: string): Promise<string> {
|
||||
return await processSetting.encodeSettingsToSetupURI(settings, passphrase);
|
||||
}
|
||||
|
||||
function captureStdout() {
|
||||
const writes: string[] = [];
|
||||
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => {
|
||||
writes.push(typeof chunk === "string" ? chunk : String(chunk));
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
spy,
|
||||
lines: () =>
|
||||
writes
|
||||
.join("")
|
||||
.split("\n")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAddedRemoteIdFromLines(lines: string[]): string {
|
||||
// remote-add prints: <id>\t<name>\t<redacted-connstr>
|
||||
const last = lines.length > 0 ? lines[lines.length - 1] : "";
|
||||
return last.split("\t")[0] || "";
|
||||
}
|
||||
|
||||
type ProtocolFixture = {
|
||||
protocol: string;
|
||||
connectionString: string;
|
||||
assertProjectedFields: (settings: any) => void;
|
||||
};
|
||||
|
||||
const protocolFixtures: ProtocolFixture[] = [
|
||||
{
|
||||
protocol: "couchdb",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "couchdb",
|
||||
settings: {
|
||||
couchDB_URI: "https://db.example.com:5984",
|
||||
couchDB_USER: "user1",
|
||||
couchDB_PASSWORD: "pass1",
|
||||
couchDB_DBNAME: "vault1",
|
||||
couchDB_CustomHeaders: "",
|
||||
useJWT: false,
|
||||
jwtAlgorithm: "",
|
||||
jwtKey: "",
|
||||
jwtKid: "",
|
||||
jwtSub: "",
|
||||
jwtExpDuration: 5,
|
||||
useRequestAPI: false,
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_COUCHDB);
|
||||
expect(settings.couchDB_URI).toBe("https://db.example.com:5984");
|
||||
expect(settings.couchDB_USER).toBe("user1");
|
||||
expect(settings.couchDB_PASSWORD).toBe("pass1");
|
||||
expect(settings.couchDB_DBNAME).toBe("vault1");
|
||||
},
|
||||
},
|
||||
{
|
||||
protocol: "s3",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "s3",
|
||||
settings: {
|
||||
accessKey: "ak",
|
||||
secretKey: "sk",
|
||||
endpoint: "https://s3.example.com",
|
||||
bucket: "bucket-1",
|
||||
region: "ap-northeast-1",
|
||||
bucketPrefix: "vault/",
|
||||
useCustomRequestHandler: true,
|
||||
bucketCustomHeaders: "x-test:1",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_MINIO);
|
||||
expect(settings.accessKey).toBe("ak");
|
||||
expect(settings.secretKey).toBe("sk");
|
||||
expect(settings.endpoint).toBe("https://s3.example.com");
|
||||
expect(settings.bucket).toBe("bucket-1");
|
||||
expect(settings.region).toBe("ap-northeast-1");
|
||||
},
|
||||
},
|
||||
{
|
||||
protocol: "p2p",
|
||||
connectionString: ConnectionStringParser.serialize({
|
||||
type: "p2p",
|
||||
settings: {
|
||||
P2P_Enabled: false,
|
||||
P2P_roomID: "room-abc",
|
||||
P2P_passphrase: "pass-123",
|
||||
P2P_relays: "wss://relay.example",
|
||||
P2P_AppID: "self-hosted-livesync",
|
||||
P2P_AutoStart: true,
|
||||
P2P_AutoBroadcast: false,
|
||||
P2P_turnServers: "turn:turn.example:3478",
|
||||
P2P_turnUsername: "turn-user",
|
||||
P2P_turnCredential: "turn-pass",
|
||||
},
|
||||
}),
|
||||
assertProjectedFields: (settings) => {
|
||||
expect(settings.remoteType).toBe(REMOTE_P2P);
|
||||
expect(settings.P2P_roomID).toBe("room-abc");
|
||||
expect(settings.P2P_passphrase).toBe("pass-123");
|
||||
expect(settings.P2P_relays).toBe("wss://relay.example");
|
||||
expect(settings.P2P_AppID).toBe("self-hosted-livesync");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("runCommand abnormal cases", () => {
|
||||
const context = {
|
||||
databasePath: "/tmp/vault",
|
||||
@@ -202,4 +322,254 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-add stores canonical URI and prints the created id", async () => {
|
||||
const core = createCoreMock();
|
||||
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
|
||||
const result = await runCommand(makeOptions("remote-add", ["my-remote", "sls+https://example.com/db"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const ids = Object.keys(settings.remoteConfigurations);
|
||||
expect(ids.length).toBe(1);
|
||||
expect(settings.remoteConfigurations[ids[0]].name).toBe("my-remote");
|
||||
expect(settings.remoteConfigurations[ids[0]].uri).toContain("sls+https://example.com/db");
|
||||
expect(settings.activeConfigurationId).toBe(ids[0]);
|
||||
expect(stdout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-activate switches active remote and applies settings", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://example.com/db1",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: "sls+https://example.com/db2",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-activate", ["r2"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("remote-rm removes active remote and promotes first remaining", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://example.com/db1",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: "sls+https://example.com/db2",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-rm", ["r1"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.remoteConfigurations.r1).toBeUndefined();
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("remote-export prints the exact stored connection string", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://example.com/db?db=vault",
|
||||
isEncrypted: false,
|
||||
};
|
||||
const stdout = captureStdout();
|
||||
|
||||
const result = await runCommand(makeOptions("remote-export", ["r1"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
const outLines = stdout.lines();
|
||||
expect(outLines.length > 0 ? outLines[outLines.length - 1] : "").toBe("sls+https://example.com/db?db=vault");
|
||||
expect(stdout.spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remote-set updates URI and applies settings when target is active", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/db",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-set", ["r1", "sls+https://new.example/db"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.remoteConfigurations.r1.uri).toContain("sls+https://new.example/db");
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(protocolFixtures)(
|
||||
"remote-activate projects effective settings for $protocol",
|
||||
async ({ connectionString, assertProjectedFields }) => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: connectionString,
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-activate", ["r2"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
assertProjectedFields(settings);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(protocolFixtures)(
|
||||
"remote-set projects effective settings for active remote ($protocol)",
|
||||
async ({ connectionString, assertProjectedFields }) => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-set", ["r1", connectionString]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
assertProjectedFields(settings);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(protocolFixtures)(
|
||||
"remote-rm projects promoted active remote effective settings for $protocol",
|
||||
async ({ connectionString, assertProjectedFields }) => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteConfigurations.r1 = {
|
||||
id: "r1",
|
||||
name: "R1",
|
||||
uri: "sls+https://old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.remoteConfigurations.r2 = {
|
||||
id: "r2",
|
||||
name: "R2",
|
||||
uri: connectionString,
|
||||
isEncrypted: false,
|
||||
};
|
||||
settings.activeConfigurationId = "r1";
|
||||
|
||||
const result = await runCommand(makeOptions("remote-rm", ["r1"]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(settings.activeConfigurationId).toBe("r2");
|
||||
assertProjectedFields(settings);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
["couchdb", "sls+https://user:pass@example.com:5984/?db=vault"] as const,
|
||||
[
|
||||
"s3",
|
||||
"sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=my-bucket®ion=ap-northeast-1",
|
||||
] as const,
|
||||
[
|
||||
"p2p",
|
||||
"sls+p2p://room-abc?passphrase=pass-123&relays=wss%3A%2F%2Frelay.example&appId=self-hosted-livesync",
|
||||
] as const,
|
||||
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
|
||||
const core = createCoreMock();
|
||||
|
||||
const addOut = captureStdout();
|
||||
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(addResult).toBe(true);
|
||||
const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
|
||||
expect(remoteId).not.toBe("");
|
||||
|
||||
const export1Out = captureStdout();
|
||||
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(export1Result).toBe(true);
|
||||
const export1Lines = export1Out.lines();
|
||||
const exported1 = export1Lines.length > 0 ? export1Lines[export1Lines.length - 1] : "";
|
||||
expect(exported1).toBe(ConnectionStringParser.serialize(ConnectionStringParser.parse(initialConnStr)));
|
||||
|
||||
const roundTripInput = ConnectionStringParser.serialize(ConnectionStringParser.parse(exported1));
|
||||
const setResult = await runCommand(makeOptions("remote-set", [remoteId, roundTripInput]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(setResult).toBe(true);
|
||||
|
||||
const export2Out = captureStdout();
|
||||
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
expect(export2Result).toBe(true);
|
||||
const export2Lines = export2Out.lines();
|
||||
const exported2 = export2Lines.length > 0 ? export2Lines[export2Lines.length - 1] : "";
|
||||
expect(exported2).toBe(roundTripInput);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,12 @@ export type CLICommand =
|
||||
| "rm"
|
||||
| "resolve"
|
||||
| "mirror"
|
||||
| "remote-add"
|
||||
| "remote-rm"
|
||||
| "remote-ls"
|
||||
| "remote-export"
|
||||
| "remote-set"
|
||||
| "remote-activate"
|
||||
| "init-settings";
|
||||
|
||||
export interface CLIOptions {
|
||||
@@ -37,7 +43,16 @@ export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
core: LiveSyncBaseCore<ServiceContext, any>;
|
||||
settingsPath: string;
|
||||
originalSyncSettings: Pick<ObsidianLiveSyncSettings, "liveSync" | "syncOnStart" | "periodicReplication" | "syncOnSave" | "syncOnEditorSave" | "syncOnFileOpen" | "syncAfterMerge">;
|
||||
originalSyncSettings: Pick<
|
||||
ObsidianLiveSyncSettings,
|
||||
| "liveSync"
|
||||
| "syncOnStart"
|
||||
| "periodicReplication"
|
||||
| "syncOnSave"
|
||||
| "syncOnEditorSave"
|
||||
| "syncOnFileOpen"
|
||||
| "syncAfterMerge"
|
||||
>;
|
||||
}
|
||||
|
||||
export const VALID_COMMANDS = new Set([
|
||||
@@ -58,5 +73,11 @@ export const VALID_COMMANDS = new Set([
|
||||
"rm",
|
||||
"resolve",
|
||||
"mirror",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-ls",
|
||||
"remote-export",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
"init-settings",
|
||||
] as const);
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as path from "path";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
||||
import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub";
|
||||
@@ -27,6 +26,7 @@ import type { CLICommand, CLIOptions } from "./commands/types";
|
||||
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
||||
import { 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";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -62,6 +62,16 @@ Commands:
|
||||
rm <path> Mark a file as deleted in local database
|
||||
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)
|
||||
remote-add <name> <connstr>
|
||||
Add a remote configuration from a connection string
|
||||
remote-rm <remote-id> Remove a remote configuration by ID
|
||||
remote-ls List stored remote configurations
|
||||
remote-export <remote-id>
|
||||
Export a remote connection string by ID
|
||||
remote-set <remote-id> <connstr>
|
||||
Replace a stored remote connection string by ID
|
||||
remote-activate <remote-id>
|
||||
Activate a stored remote configuration by ID
|
||||
|
||||
Options:
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
@@ -84,6 +94,12 @@ Examples:
|
||||
livesync-cli ./my-database info notes/hello.md
|
||||
livesync-cli ./my-database rm notes/hello.md
|
||||
livesync-cli ./my-database resolve notes/hello.md 3-abcdef
|
||||
livesync-cli ./my-database remote-add my-remote "sls+https://user:pass@example.com/db"
|
||||
livesync-cli ./my-database remote-ls
|
||||
livesync-cli ./my-database remote-export remote-abc123
|
||||
livesync-cli ./my-database remote-set remote-abc123 "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=mybucket"
|
||||
livesync-cli ./my-database remote-activate remote-abc123
|
||||
livesync-cli ./my-database remote-rm remote-abc123
|
||||
livesync-cli init-settings ./data.json
|
||||
livesync-cli ./my-database --verbose
|
||||
`);
|
||||
@@ -229,6 +245,9 @@ export async function main() {
|
||||
options.command === "cat" ||
|
||||
options.command === "cat-rev" ||
|
||||
options.command === "ls" ||
|
||||
options.command === "remote-add" ||
|
||||
options.command === "remote-ls" ||
|
||||
options.command === "remote-export" ||
|
||||
options.command === "p2p-peers" ||
|
||||
options.command === "info" ||
|
||||
options.command === "rm" ||
|
||||
@@ -280,16 +299,13 @@ export async function main() {
|
||||
// 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;
|
||||
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
|
||||
const context = new NodeServiceContext(databasePath);
|
||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||
@@ -371,12 +387,11 @@ export async function main() {
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
||||
},
|
||||
(core) => [
|
||||
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
||||
// new ModuleReplicatorP2P(core),
|
||||
],
|
||||
(core) => [],
|
||||
() => [], // No add-ons
|
||||
(core) => {
|
||||
// Register P2P replicator feature.
|
||||
const _replicator = useP2PReplicatorFeature(core);
|
||||
// Add target filter to prevent internal files are handled
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
@@ -427,7 +442,7 @@ export async function main() {
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// 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);
|
||||
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.
|
||||
|
||||
@@ -86,6 +86,56 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses remote-add command", () => {
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
"./databasePath",
|
||||
"remote-add",
|
||||
"my-remote",
|
||||
"sls+https://user:pass@example.com/db",
|
||||
];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-add");
|
||||
expect(parsed.commandArgs).toEqual(["my-remote", "sls+https://user:pass@example.com/db"]);
|
||||
});
|
||||
|
||||
it("parses remote-activate command", () => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "remote-activate", "remote-abc"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-activate");
|
||||
expect(parsed.commandArgs).toEqual(["remote-abc"]);
|
||||
});
|
||||
|
||||
it("parses remote-export command", () => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "remote-export", "remote-abc"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-export");
|
||||
expect(parsed.commandArgs).toEqual(["remote-abc"]);
|
||||
});
|
||||
|
||||
it("parses remote-set command", () => {
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
"./databasePath",
|
||||
"remote-set",
|
||||
"remote-abc",
|
||||
"sls+p2p://room-1?passphrase=abc",
|
||||
];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.databasePath).toBe("./databasePath");
|
||||
expect(parsed.command).toBe("remote-set");
|
||||
expect(parsed.commandArgs).toEqual(["remote-abc", "sls+p2p://room-1?passphrase=abc"]);
|
||||
});
|
||||
|
||||
it("parses --interval flag with valid integer", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
@@ -97,7 +97,11 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
private _watcher: FSWatcher | undefined;
|
||||
|
||||
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {}
|
||||
constructor(
|
||||
private basePath: string,
|
||||
private ignoreRules?: IgnoreRules,
|
||||
private watchEnabled: boolean = false
|
||||
) {}
|
||||
|
||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||
return {
|
||||
|
||||
@@ -60,10 +60,7 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||
expect(chokidar.watch).toHaveBeenCalledWith(
|
||||
"/base",
|
||||
expect.objectContaining({ ignoreInitial: true })
|
||||
);
|
||||
expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
|
||||
});
|
||||
|
||||
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export function initialiseServiceModulesCLI(
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled: boolean = false,
|
||||
watchEnabled: boolean = false
|
||||
): ServiceModules {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
|
||||
@@ -39,13 +39,19 @@ export function initialiseServiceModulesCLI(
|
||||
});
|
||||
|
||||
// CLI-specific storage event manager
|
||||
const storageEventManager = new StorageEventManagerCLI(basePath, core, {
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
}, ignoreRules, watchEnabled);
|
||||
const storageEventManager = new StorageEventManagerCLI(
|
||||
basePath,
|
||||
core,
|
||||
{
|
||||
fileProcessing: services.fileProcessing,
|
||||
setting: services.setting,
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
},
|
||||
ignoreRules,
|
||||
watchEnabled
|
||||
);
|
||||
|
||||
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||
services.appLifecycle.onUnload.addHandler(async () => {
|
||||
|
||||
@@ -55,7 +55,9 @@ export class IgnoreRules {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("import:")) {
|
||||
console.error(`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`);
|
||||
console.error(
|
||||
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
this._addPattern(trimmed);
|
||||
@@ -105,7 +107,7 @@ export class IgnoreRules {
|
||||
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.`
|
||||
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
||||
);
|
||||
}
|
||||
this.patterns.push(this._normalisePattern(raw));
|
||||
|
||||
@@ -122,10 +122,7 @@ describe("IgnoreRules", () => {
|
||||
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"
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -29,7 +29,8 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
|
||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
||||
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
||||
|
||||
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||
const minioEndpoint =
|
||||
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||
|
||||
@@ -47,7 +47,8 @@ function injectBanner(): import("vite").Plugin {
|
||||
// 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);
|
||||
chunk.code =
|
||||
chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||
} else {
|
||||
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
||||
|
||||
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||
|
||||
listEl.innerHTML = "";
|
||||
listEl.replaceChildren();
|
||||
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
||||
|
||||
for (const item of items) {
|
||||
|
||||
142
src/common/reportTool.ts
Normal file
142
src/common/reportTool.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/models/setting.const";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
||||
import { generateCredentialObject } from "@lib/replication/httplib";
|
||||
import { parseHeaderValues } from "@lib/common/utils";
|
||||
import { requestToCouchDBWithCredentials } from "./utils";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger";
|
||||
import { DEFAULT_SETTINGS } from "@lib/common/models/setting.const.defaults";
|
||||
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions";
|
||||
import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
function redactObject(obj: Record<string, any>, dotted: string, redactedValue = "REDACTED") {
|
||||
const keys = dotted.split(".");
|
||||
let current = obj;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (!(key in current)) {
|
||||
current[key] = {} as Record<string, any>;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
current = current[key];
|
||||
}
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey in current) {
|
||||
current[lastKey] = redactedValue;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) {
|
||||
let responseConfig: Record<string, any> = {};
|
||||
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
|
||||
if (settings.remoteType == REMOTE_COUCHDB) {
|
||||
try {
|
||||
const credential = generateCredentialObject(settings);
|
||||
const customHeaders = parseHeaderValues(settings.couchDB_CustomHeaders);
|
||||
const r = await requestToCouchDBWithCredentials(
|
||||
settings.couchDB_URI,
|
||||
credential,
|
||||
window.origin,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
responseConfig = r.json as Record<string, any>;
|
||||
redactObject(responseConfig, "couch_httpd_auth.secret");
|
||||
redactObject(responseConfig, "couch_httpd_auth.authentication_db");
|
||||
redactObject(responseConfig, "couch_httpd_auth.authentication_redirect");
|
||||
redactObject(responseConfig, "couchdb.uuid");
|
||||
redactObject(responseConfig, "admins");
|
||||
redactObject(responseConfig, "users");
|
||||
redactObject(responseConfig, "chttpd_auth.secret");
|
||||
delete responseConfig["jwt_keys"];
|
||||
} catch (ex) {
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
responseConfig = {
|
||||
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
|
||||
};
|
||||
}
|
||||
} else if (settings.remoteType == REMOTE_MINIO) {
|
||||
responseConfig = { error: "Object Storage Synchronisation" };
|
||||
//
|
||||
}
|
||||
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
|
||||
const pluginConfig = JSON.parse(JSON.stringify(settings)) as ObsidianLiveSyncSettings;
|
||||
const pluginKeys = Object.keys(pluginConfig);
|
||||
for (const key of pluginKeys) {
|
||||
if (defaultKeys.includes(key as keyof ObsidianLiveSyncSettings)) continue;
|
||||
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
|
||||
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.couchDB_URI.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`;
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
|
||||
const toSchemeOnly = (uri: string) => {
|
||||
try {
|
||||
return `${new URL(uri).protocol}//`;
|
||||
} catch {
|
||||
const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//);
|
||||
return matched?.[0] ?? REDACTED;
|
||||
}
|
||||
};
|
||||
pluginConfig.remoteConfigurations = Object.fromEntries(
|
||||
Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [
|
||||
id,
|
||||
{
|
||||
...config,
|
||||
uri: toSchemeOnly(config.uri),
|
||||
},
|
||||
])
|
||||
);
|
||||
pluginConfig.region = redact(pluginConfig.region);
|
||||
pluginConfig.bucket = redact(pluginConfig.bucket);
|
||||
pluginConfig.pluginSyncExtendedSetting = {};
|
||||
pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID);
|
||||
pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase);
|
||||
pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID);
|
||||
pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays);
|
||||
pluginConfig.jwtKey = redact(pluginConfig.jwtKey);
|
||||
pluginConfig.jwtSub = redact(pluginConfig.jwtSub);
|
||||
pluginConfig.jwtKid = redact(pluginConfig.jwtKid);
|
||||
pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders);
|
||||
pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders);
|
||||
pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential);
|
||||
pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername);
|
||||
pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`;
|
||||
const endpoint = pluginConfig.endpoint;
|
||||
if (endpoint == "") {
|
||||
pluginConfig.endpoint = "Not configured or AWS";
|
||||
} else {
|
||||
const endpointScheme = pluginConfig.endpoint.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.endpoint.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`;
|
||||
}
|
||||
const obsidianInfo = {
|
||||
navigator: compatGlobal.navigator.userAgent,
|
||||
fileSystem: core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive",
|
||||
};
|
||||
const result = {
|
||||
obsidianInfo,
|
||||
responseConfig,
|
||||
pluginConfig,
|
||||
manifestVersion,
|
||||
packageVersion,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@@ -128,7 +128,7 @@ export const _requestToCouchDBFetch = async (
|
||||
username: string,
|
||||
password: string,
|
||||
path?: string,
|
||||
body?: string | any,
|
||||
body?: any,
|
||||
method?: string
|
||||
) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
@@ -146,7 +146,7 @@ export const _requestToCouchDBFetch = async (
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
return await fetch(uri, requestParam);
|
||||
return await _fetch(uri, requestParam);
|
||||
};
|
||||
|
||||
export const _requestToCouchDB = async (
|
||||
@@ -214,6 +214,7 @@ import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.cons
|
||||
export { BASE_IS_NEW, EVEN, TARGET_IS_NEW };
|
||||
// Why 2000? : ZIP FILE Does not have enough resolution.
|
||||
import { compareMTime } from "@lib/common/utils.ts";
|
||||
import { _fetch } from "@/lib/src/common/coreEnvFunctions.ts";
|
||||
export { compareMTime };
|
||||
function getKey(file: AnyEntry | string | UXFileInfoStub) {
|
||||
const key = typeof file == "string" ? file : stripAllPrefixes(file.path);
|
||||
|
||||
@@ -68,9 +68,10 @@ import { ConflictResolveModal } from "../../modules/features/InteractiveConflict
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "../../common/events.ts";
|
||||
import { PluginDialogModal } from "./PluginDialogModal.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { $msg } from "@/lib/src/common/i18n.ts";
|
||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
@@ -564,7 +565,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
...data,
|
||||
documentPath: this.getPath(wx),
|
||||
files: xFiles,
|
||||
} as PluginDataExDisplay;
|
||||
} satisfies PluginDataExDisplay;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1069,10 +1070,10 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
const baseDir = this.configDir;
|
||||
try {
|
||||
if (!data.documentPath) throw "InternalError: Document path not exist";
|
||||
if (!data.documentPath) throw new LiveSyncError("InternalError: Document path not exist");
|
||||
const dx = await this.localDatabase.getDBEntry(data.documentPath);
|
||||
if (dx == false) {
|
||||
throw "Not found on database";
|
||||
throw new LiveSyncError("Not found on database");
|
||||
}
|
||||
const loadedData = deserialize(getDocDataAsArray(dx.data), {}) as PluginDataEx;
|
||||
for (const f of loadedData.files) {
|
||||
@@ -1317,7 +1318,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
const docXDoc = await this.localDatabase.getDBEntryFromMeta(old, false, false);
|
||||
if (docXDoc == false) {
|
||||
throw "Could not load the document";
|
||||
throw new LiveSyncError("Could not load the document");
|
||||
}
|
||||
const dataSrc = getDocData(docXDoc.data);
|
||||
const dataStart = dataSrc.indexOf(DUMMY_END);
|
||||
|
||||
@@ -50,6 +50,7 @@ import { hiddenFilesEventCount, hiddenFilesProcessingCount } from "../../lib/src
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "../../common/events.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { tryGetFilePath } from "@lib/common/utils.doc.ts";
|
||||
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
|
||||
|
||||
declare global {
|
||||
@@ -317,7 +318,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
this._fileInfoLastProcessed.set(file, key);
|
||||
}
|
||||
|
||||
async updateLastProcessedAsActualFile(file: FilePath, stat?: UXStat | null | undefined) {
|
||||
async updateLastProcessedAsActualFile(file: FilePath, stat?: UXStat | null) {
|
||||
if (!stat) stat = await this.core.storageAccess.statHidden(file);
|
||||
this._fileInfoLastProcessed.set(file, this.statToKey(stat));
|
||||
}
|
||||
@@ -411,10 +412,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
}
|
||||
}
|
||||
|
||||
async updateLastProcessedAsActualDatabase(
|
||||
file: FilePath,
|
||||
doc?: MetaEntry | LoadedEntry | null | undefined | false
|
||||
) {
|
||||
async updateLastProcessedAsActualDatabase(file: FilePath, doc?: MetaEntry | LoadedEntry | null | false) {
|
||||
const dbPath = addPrefix(file, ICHeader);
|
||||
if (!doc) doc = await this.localDatabase.getDBEntryMeta(dbPath);
|
||||
if (!doc) return;
|
||||
@@ -1050,7 +1048,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
}
|
||||
notifyProgress();
|
||||
} catch (ex) {
|
||||
this._log(`Failed to process storage change file:${file}`, logLevel);
|
||||
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
});
|
||||
@@ -1162,7 +1160,7 @@ Offline Changed files: ${files.length}`;
|
||||
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
|
||||
notifyProgress();
|
||||
} catch (ex) {
|
||||
this._log(`Failed to process database changes:${file}`);
|
||||
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return;
|
||||
@@ -1500,7 +1498,7 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
const storeFilePath = stripAllPrefixes(file.path as FilePath);
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
return undefined;
|
||||
|
||||
@@ -16,9 +16,8 @@ import { serialized } from "octagonal-wheels/concurrency/lock_v2";
|
||||
import { arrayToChunkedArray } from "octagonal-wheels/collection";
|
||||
import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events";
|
||||
import type { LiveSyncCouchDBReplicator } from "@/lib/src/replication/couchdb/LiveSyncReplicator";
|
||||
import { delay, parseHeaderValues } from "@/lib/src/common/utils";
|
||||
import { generateCredentialObject } from "@/lib/src/replication/httplib";
|
||||
import { _requestToCouchDB } from "@/common/utils";
|
||||
import { delay } from "@/lib/src/common/utils";
|
||||
// import { _requestToCouchDB } from "@/common/utils";
|
||||
const DB_KEY_SEQ = "gc-seq";
|
||||
const DB_KEY_CHUNK_SET = "chunk-set";
|
||||
const DB_KEY_DOC_USAGE_MAP = "doc-usage-map";
|
||||
@@ -391,7 +390,7 @@ Note: **Make sure to synchronise all devices before deletion.**
|
||||
.map((revInfo) => db.get(doc._id, { rev: revInfo.rev }))
|
||||
).then((docs) => docs.filter((doc) => doc));
|
||||
for (const oldDoc of oldDocs) {
|
||||
await processDoc(oldDoc as EntryDoc, false);
|
||||
await processDoc(oldDoc, false);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
@@ -533,7 +532,7 @@ Success: ${successCount}, Errored: ${errored}`;
|
||||
const docMap = new Map<DocumentID, Set<DocumentInfo>>();
|
||||
const info = await db.info();
|
||||
// Total number of revisions to process (approximate)
|
||||
const maxSeq = new Number(info.update_seq);
|
||||
const maxSeq = Number.parseInt(`${info.update_seq ?? 0}`, 10);
|
||||
let processed = 0;
|
||||
let read = 0;
|
||||
let errored = 0;
|
||||
@@ -560,7 +559,7 @@ Success: ${successCount}, Errored: ${errored}`;
|
||||
});
|
||||
docMap.set(id, set);
|
||||
} else if (doc.type === EntryTypes.CHUNK) {
|
||||
const id = doc._id as DocumentID;
|
||||
const id = doc._id;
|
||||
if (chunkMap.has(id)) {
|
||||
return;
|
||||
}
|
||||
@@ -759,68 +758,68 @@ Success: ${successCount}, Errored: ${errored}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact the database by temporarily setting the revision limit to 1.
|
||||
* @returns
|
||||
*/
|
||||
async compactDatabaseWithRevLimit() {
|
||||
// Temporarily set revs_limit to 1, perform compaction, and restore the original revs_limit.
|
||||
// Very dangerous operation, so now suppressed.
|
||||
return false;
|
||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
||||
const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
|
||||
if (!remote) {
|
||||
this._notice("Failed to connect to remote for compaction.");
|
||||
return;
|
||||
}
|
||||
if (typeof remote == "string") {
|
||||
this._notice(`Failed to connect to remote for compaction. ${remote}`);
|
||||
return;
|
||||
}
|
||||
const customHeaders = parseHeaderValues(this.settings.couchDB_CustomHeaders);
|
||||
const credential = generateCredentialObject(this.settings);
|
||||
const request = async (path: string, method: string = "GET", body: any = undefined) => {
|
||||
const req = await _requestToCouchDB(
|
||||
this.settings.couchDB_URI.replace(/\/+$/, "") +
|
||||
(this.settings.couchDB_DBNAME ? `/${this.settings.couchDB_DBNAME}` : ""),
|
||||
credential,
|
||||
window.origin,
|
||||
path,
|
||||
body,
|
||||
method,
|
||||
customHeaders
|
||||
);
|
||||
return req;
|
||||
};
|
||||
let revsLimit = "";
|
||||
const req = await request(`_revs_limit`, "GET");
|
||||
if (req.status == 200) {
|
||||
revsLimit = req.text.trim();
|
||||
this._info(`Remote database _revs_limit: ${revsLimit}`);
|
||||
} else {
|
||||
this._notice(`Failed to get remote database _revs_limit. Status: ${req.status}`);
|
||||
return;
|
||||
}
|
||||
const req2 = await request(`_revs_limit`, "PUT", 1);
|
||||
if (req2.status == 200) {
|
||||
this._info(`Set remote database _revs_limit to 1 for compaction.`);
|
||||
}
|
||||
try {
|
||||
await this.compactDatabase();
|
||||
} finally {
|
||||
// Restore revs_limit
|
||||
if (revsLimit) {
|
||||
const req3 = await request(`_revs_limit`, "PUT", parseInt(revsLimit));
|
||||
if (req3.status == 200) {
|
||||
this._info(`Restored remote database _revs_limit to ${revsLimit}.`);
|
||||
} else {
|
||||
this._notice(
|
||||
`Failed to restore remote database _revs_limit. Status: ${req3.status} / ${req3.text}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// /**
|
||||
// * Compact the database by temporarily setting the revision limit to 1.
|
||||
// * @returns
|
||||
// */
|
||||
// async compactDatabaseWithRevLimit() {
|
||||
// // Temporarily set revs_limit to 1, perform compaction, and restore the original revs_limit.
|
||||
// // Very dangerous operation, so now suppressed.
|
||||
// return Promise.resolve(false);
|
||||
// const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
||||
// const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
|
||||
// if (!remote) {
|
||||
// this._notice("Failed to connect to remote for compaction.");
|
||||
// return;
|
||||
// }
|
||||
// if (typeof remote == "string") {
|
||||
// this._notice(`Failed to connect to remote for compaction. ${remote}`);
|
||||
// return;
|
||||
// }
|
||||
// const customHeaders = parseHeaderValues(this.settings.couchDB_CustomHeaders);
|
||||
// const credential = generateCredentialObject(this.settings);
|
||||
// const request = async (path: string, method: string = "GET", body: any = undefined) => {
|
||||
// const req = await _requestToCouchDB(
|
||||
// this.settings.couchDB_URI.replace(/\/+$/, "") +
|
||||
// (this.settings.couchDB_DBNAME ? `/${this.settings.couchDB_DBNAME}` : ""),
|
||||
// credential,
|
||||
// window.origin,
|
||||
// path,
|
||||
// body,
|
||||
// method,
|
||||
// customHeaders
|
||||
// );
|
||||
// return req;
|
||||
// };
|
||||
// let revsLimit = "";
|
||||
// const req = await request(`_revs_limit`, "GET");
|
||||
// if (req.status == 200) {
|
||||
// revsLimit = req.text.trim();
|
||||
// this._info(`Remote database _revs_limit: ${revsLimit}`);
|
||||
// } else {
|
||||
// this._notice(`Failed to get remote database _revs_limit. Status: ${req.status}`);
|
||||
// return;
|
||||
// }
|
||||
// const req2 = await request(`_revs_limit`, "PUT", 1);
|
||||
// if (req2.status == 200) {
|
||||
// this._info(`Set remote database _revs_limit to 1 for compaction.`);
|
||||
// }
|
||||
// try {
|
||||
// await this.compactDatabase();
|
||||
// } finally {
|
||||
// // Restore revs_limit
|
||||
// if (revsLimit) {
|
||||
// const req3 = await request(`_revs_limit`, "PUT", parseInt(revsLimit));
|
||||
// if (req3.status == 200) {
|
||||
// this._info(`Restored remote database _revs_limit to ${revsLimit}.`);
|
||||
// } else {
|
||||
// this._notice(
|
||||
// `Failed to restore remote database _revs_limit. Status: ${req3.status} / ${req3.text}`
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
async gcv3() {
|
||||
if (!this.isAvailable()) return;
|
||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
||||
@@ -929,7 +928,7 @@ This may indicate that some devices have not completed synchronisation, which co
|
||||
usedChunks.add(chunkId);
|
||||
}
|
||||
} else if (doc.type === EntryTypes.CHUNK) {
|
||||
allChunks.set(doc._id as DocumentID, doc._rev);
|
||||
allChunks.set(doc._id, doc._rev);
|
||||
}
|
||||
}
|
||||
this._notice(
|
||||
|
||||
@@ -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,
|
||||
ConnectionStatus,
|
||||
type PeerStatus,
|
||||
} from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { LiveSyncTrysteroReplicator } from "../../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
} from "@lib/replication/trystero/P2PReplicatorPaneCommon";
|
||||
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "@/common/events";
|
||||
import {
|
||||
type PeerInfo,
|
||||
type P2PServerInfo,
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import { $msg as _msg } from "../../../lib/src/common/i18n";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types";
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { type P2PReplicatorStatus } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import { $msg as _msg } from "@lib/common/i18n";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
|
||||
import { generateP2PRoomId } from "@lib/common/utils";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
|
||||
interface Props {
|
||||
@@ -148,6 +149,7 @@
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
return () => {
|
||||
r();
|
||||
rx();
|
||||
r2();
|
||||
r3();
|
||||
};
|
||||
@@ -216,18 +218,8 @@
|
||||
function useDefaultRelay() {
|
||||
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() {
|
||||
eRoomId = generateRandom(12) + "-" + Math.random().toString(36).substring(2, 5);
|
||||
eRoomId = generateP2PRoomId();
|
||||
}
|
||||
|
||||
async function openServer() {
|
||||
@@ -251,7 +243,7 @@
|
||||
setting?: boolean;
|
||||
};
|
||||
return initialDialogStatus;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
310
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
310
src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte
Normal file
@@ -0,0 +1,310 @@
|
||||
<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 ?? ""));
|
||||
let useDiagRTC = $state<boolean>(core?.services.setting.currentSettings()?.P2P_useDiagRTC ?? false);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDiagRTC() {
|
||||
if (!core) {
|
||||
return;
|
||||
}
|
||||
const next = !useDiagRTC;
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
settings.P2P_useDiagRTC = next;
|
||||
return settings;
|
||||
}, true);
|
||||
useDiagRTC = next;
|
||||
}
|
||||
|
||||
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 ?? "");
|
||||
useDiagRTC = settings?.P2P_useDiagRTC ?? false;
|
||||
});
|
||||
|
||||
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}
|
||||
|
||||
{#if core}
|
||||
<div class="status-item status-action diag-toggle-row">
|
||||
<label class="broadcast-label" for="diag-toggle">
|
||||
🕵️ Diag
|
||||
</label>
|
||||
<button
|
||||
id="diag-toggle"
|
||||
class="broadcast-button {useDiagRTC ? 'is-on' : 'is-off'}"
|
||||
onclick={toggleDiagRTC}
|
||||
title={useDiagRTC
|
||||
? 'Diagnostic RTCPeerConnection is enabled'
|
||||
: 'Use Diagnostic RTCPeerConnection for statistics'}
|
||||
>
|
||||
{useDiagRTC ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverInfo}
|
||||
<div class="diag-section">
|
||||
<h4>Stats</h4>
|
||||
<div class="diag-grid">
|
||||
<div class="diag-item">
|
||||
<span>Incoming:</span>
|
||||
<span>{serverInfo.diag.totalNewConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Connected:</span>
|
||||
<span>{serverInfo.diag.totalSuccessfulConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Failed:</span>
|
||||
<span>{serverInfo.diag.totalFailedConnections}</span>
|
||||
</div>
|
||||
<div class="diag-item">
|
||||
<span>Closed:</span>
|
||||
<span>{serverInfo.diag.totalClosedConnections}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.diag-toggle-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);
|
||||
}
|
||||
|
||||
.diag-section {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.diag-section h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.diag-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
891
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
891
src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte
Normal file
@@ -0,0 +1,891 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { EVENT_LAYOUT_READY, EVENT_REQUEST_OPEN_P2P_SETTINGS, eventHub } from "@/common/events";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_REQUEST_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
EVENT_P2P_REPLICATOR_PROGRESS,
|
||||
type P2PServerInfo,
|
||||
} from "@lib/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { P2PReplicatorStatus, P2PReplicationReport } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import { delay, fireAndForget } from "octagonal-wheels/promises";
|
||||
import P2PServerStatusCard from "./P2PServerStatusCard.svelte";
|
||||
import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString";
|
||||
import type { P2PSyncSetting, RemoteConfiguration } from "@lib/common/models/setting.type";
|
||||
import { activateP2PRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
|
||||
import { extractP2PRoomSuffix } from "@lib/common/utils";
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
|
||||
interface Props {
|
||||
liveSyncReplicator: LiveSyncTrysteroReplicator;
|
||||
core: LiveSyncBaseCore;
|
||||
}
|
||||
|
||||
let { liveSyncReplicator, core }: Props = $props();
|
||||
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
|
||||
let replicatorInfo = $state<P2PReplicatorStatus | undefined>(undefined);
|
||||
let decidingPeerId = $state<string | null>(null);
|
||||
let replicatingPeerId = $state<string | null>(null);
|
||||
let communicatingUntil = $state<Record<string, number>>({});
|
||||
const COMMUNICATION_HOLD_MS = 2500;
|
||||
let syncOnReplicationSetting = $state(core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "");
|
||||
type P2PRemoteOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
roomSuffix: string;
|
||||
};
|
||||
let p2pRemoteOptions = $state<P2PRemoteOption[]>([]);
|
||||
let selectedP2PRemoteConfigurationId = $state(
|
||||
core.services.setting.currentSettings()?.P2P_ActiveRemoteConfigurationId ?? ""
|
||||
);
|
||||
let selectingP2PRemote = $state(false);
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
const items = list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e);
|
||||
if (!items.includes(item)) items.push(item);
|
||||
return items.join(",");
|
||||
}
|
||||
function removeFromList(item: string, list: string): string {
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e && e !== item)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function markCommunicating(peerId: string) {
|
||||
const expiry = Date.now() + COMMUNICATION_HOLD_MS;
|
||||
communicatingUntil = { ...communicatingUntil, [peerId]: expiry };
|
||||
window.setTimeout(() => {
|
||||
if ((communicatingUntil[peerId] ?? 0) <= Date.now()) {
|
||||
const { [peerId]: _removed, ...rest } = communicatingUntil;
|
||||
communicatingUntil = rest;
|
||||
}
|
||||
}, COMMUNICATION_HOLD_MS + 100);
|
||||
}
|
||||
|
||||
function listP2PRemoteOptions(
|
||||
remoteConfigurations: Record<string, RemoteConfiguration> | undefined
|
||||
): P2PRemoteOption[] {
|
||||
return Object.values(remoteConfigurations ?? {})
|
||||
.map((config) => {
|
||||
try {
|
||||
const parsed = ConnectionStringParser.parse(config.uri);
|
||||
if (parsed.type !== "p2p") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
roomSuffix: extractP2PRoomSuffix(parsed.settings.P2P_roomID ?? ""),
|
||||
} as P2PRemoteOption;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((e): e is P2PRemoteOption => !!e);
|
||||
}
|
||||
|
||||
function refreshP2PRemoteOptions() {
|
||||
const settings = core.services.setting.currentSettings();
|
||||
const options = listP2PRemoteOptions(settings.remoteConfigurations);
|
||||
p2pRemoteOptions = options;
|
||||
const currentSelected = settings.P2P_ActiveRemoteConfigurationId ?? "";
|
||||
const isCurrentSelectedValid = options.some((option) => option.id === currentSelected);
|
||||
if (options.length === 0) {
|
||||
selectedP2PRemoteConfigurationId = "";
|
||||
return;
|
||||
}
|
||||
if (currentSelected.trim() === "" || !isCurrentSelectedValid) {
|
||||
const fallbackId = options[0].id;
|
||||
selectedP2PRemoteConfigurationId = fallbackId;
|
||||
if (currentSelected !== fallbackId) {
|
||||
fireAndForget(() => applyP2PActiveRemoteSelection(fallbackId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectedP2PRemoteConfigurationId = currentSelected;
|
||||
}
|
||||
|
||||
function canEditP2PSettings() {
|
||||
const selected = selectedP2PRemoteConfigurationId.trim();
|
||||
if (selected === "") {
|
||||
return false;
|
||||
}
|
||||
return p2pRemoteOptions.some((e) => e.id === selected);
|
||||
}
|
||||
|
||||
async function requestServerStatus() {
|
||||
await liveSyncReplicator.requestStatus();
|
||||
eventHub.emitEvent(EVENT_REQUEST_STATUS);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
|
||||
serverInfo = status;
|
||||
});
|
||||
const unsubscribeReplicatorStatus = eventHub.onEvent(EVENT_P2P_REPLICATOR_STATUS, (status) => {
|
||||
replicatorInfo = status;
|
||||
for (const peerId of status.replicatingFrom) {
|
||||
markCommunicating(peerId);
|
||||
}
|
||||
for (const peerId of status.replicatingTo) {
|
||||
markCommunicating(peerId);
|
||||
}
|
||||
});
|
||||
const unsubscribeReplicatorProgress = eventHub.onEvent(EVENT_P2P_REPLICATOR_PROGRESS, (report) => {
|
||||
const rep = report as P2PReplicationReport;
|
||||
if (("fetching" in rep && rep.fetching?.isActive) || ("sending" in rep && rep.sending?.isActive)) {
|
||||
markCommunicating(rep.peerId);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
syncOnReplicationSetting = settings?.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
});
|
||||
const unsubscribeLayoutReady = eventHub.onEvent(EVENT_LAYOUT_READY, () => {
|
||||
refreshP2PRemoteOptions();
|
||||
void requestServerStatus();
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
await delay(100);
|
||||
refreshP2PRemoteOptions();
|
||||
await requestServerStatus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeReplicatorStatus();
|
||||
unsubscribeReplicatorProgress();
|
||||
unsubscribeSettings();
|
||||
unsubscribeLayoutReady();
|
||||
};
|
||||
});
|
||||
|
||||
function getAcceptanceStatus(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true) return "ACCEPTED (in session)";
|
||||
if (peer.isAccepted === true) return "ACCEPTED";
|
||||
if (peer.isTemporaryAccepted === false) return "DENIED (in session)";
|
||||
if (peer.isAccepted === false) return "DENIED";
|
||||
return "NEW";
|
||||
}
|
||||
|
||||
function getAcceptanceStatusClass(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (peer.isTemporaryAccepted === true || peer.isAccepted === true) return "accepted";
|
||||
if (peer.isTemporaryAccepted === false || peer.isAccepted === false) return "denied";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function openConnectionSettings() {
|
||||
eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS);
|
||||
}
|
||||
|
||||
async function applyP2PActiveRemoteSelection(id: string) {
|
||||
selectingP2PRemote = true;
|
||||
try {
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
settings.P2P_ActiveRemoteConfigurationId = id;
|
||||
if (id.trim() === "") {
|
||||
return settings;
|
||||
}
|
||||
const activated = activateP2PRemoteConfiguration(settings, id);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
const latest = core.services.setting.currentSettings();
|
||||
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
} finally {
|
||||
selectingP2PRemote = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onP2PRemoteSelected(event: Event) {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const id = target.value;
|
||||
selectedP2PRemoteConfigurationId = id;
|
||||
await applyP2PActiveRemoteSelection(id);
|
||||
}
|
||||
|
||||
async function createAndSelectP2PRemote() {
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
const dialogManager = setupManager.dialogManager;
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSettings);
|
||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object" || !p2pConf) {
|
||||
return;
|
||||
}
|
||||
const p2pSettings = p2pConf as Partial<P2PSyncSetting>;
|
||||
const id = createRemoteConfigurationId();
|
||||
const roomSuffix = extractP2PRoomSuffix(p2pSettings.P2P_roomID ?? "");
|
||||
const name = roomSuffix ? `P2P Remote (${roomSuffix})` : "P2P Remote";
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
const merged = {
|
||||
...settings,
|
||||
...p2pSettings,
|
||||
};
|
||||
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
|
||||
settings.remoteConfigurations = {
|
||||
...(settings.remoteConfigurations ?? {}),
|
||||
[id]: {
|
||||
id,
|
||||
name,
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
};
|
||||
settings.P2P_ActiveRemoteConfigurationId = id;
|
||||
const activated = activateP2PRemoteConfiguration(settings, id);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
const latest = core.services.setting.currentSettings();
|
||||
syncOnReplicationSetting = latest.P2P_SyncOnReplication ?? "";
|
||||
refreshP2PRemoteOptions();
|
||||
}
|
||||
|
||||
async function updateSelectedP2PRemote(partial: Partial<P2PSyncSetting>) {
|
||||
const selectedId = core.services.setting.currentSettings().P2P_ActiveRemoteConfigurationId?.trim() ?? "";
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
await core.services.setting.updateSettings((settings) => {
|
||||
const config = settings.remoteConfigurations?.[selectedId];
|
||||
if (!config) {
|
||||
return settings;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
} catch {
|
||||
return settings;
|
||||
}
|
||||
if (parsed.type !== "p2p") {
|
||||
return settings;
|
||||
}
|
||||
const mergedP2P = {
|
||||
...parsed.settings,
|
||||
...partial,
|
||||
};
|
||||
const uri = ConnectionStringParser.serialize({
|
||||
type: "p2p",
|
||||
settings: {
|
||||
...settings,
|
||||
...mergedP2P,
|
||||
},
|
||||
});
|
||||
settings.remoteConfigurations = {
|
||||
...(settings.remoteConfigurations ?? {}),
|
||||
[selectedId]: {
|
||||
...config,
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
},
|
||||
};
|
||||
Object.assign(settings, partial);
|
||||
const activated = activateP2PRemoteConfiguration(settings, selectedId);
|
||||
return activated || settings;
|
||||
}, true);
|
||||
syncOnReplicationSetting = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||
}
|
||||
|
||||
async function makeDecision(
|
||||
peer: P2PServerInfo["knownAdvertisements"][number],
|
||||
decision: boolean,
|
||||
isTemporary: boolean
|
||||
) {
|
||||
decidingPeerId = peer.peerId;
|
||||
try {
|
||||
await liveSyncReplicator.makeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
decision,
|
||||
isTemporary,
|
||||
});
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
decidingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeDecision(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
decidingPeerId = peer.peerId;
|
||||
try {
|
||||
await liveSyncReplicator.revokeDecision({
|
||||
peerId: peer.peerId,
|
||||
name: peer.name,
|
||||
});
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
decidingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startReplication(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
replicatingPeerId = peer.peerId;
|
||||
try {
|
||||
const pullResult = await liveSyncReplicator.replicateFrom(peer.peerId, true);
|
||||
if (pullResult?.ok) {
|
||||
await liveSyncReplicator.requestSynchroniseToPeer(peer.peerId);
|
||||
}
|
||||
await requestServerStatus();
|
||||
} finally {
|
||||
replicatingPeerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAccepted(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
return peer.isTemporaryAccepted === true || peer.isAccepted === true;
|
||||
}
|
||||
|
||||
function isWatching(peerId: string) {
|
||||
return replicatorInfo?.watchingPeers?.includes(peerId) ?? false;
|
||||
}
|
||||
|
||||
function toggleWatch(peerId: string) {
|
||||
if (!canEditP2PSettings()) {
|
||||
return;
|
||||
}
|
||||
if (isWatching(peerId)) {
|
||||
liveSyncReplicator.unwatchPeer(peerId);
|
||||
} else {
|
||||
liveSyncReplicator.watchPeer(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
function isCommunicating(peerId: string) {
|
||||
const to = replicatorInfo?.replicatingTo ?? [];
|
||||
const from = replicatorInfo?.replicatingFrom ?? [];
|
||||
const isLiveCommunicating = to.includes(peerId) || from.includes(peerId);
|
||||
const isHeldCommunicating = (communicatingUntil[peerId] ?? 0) > Date.now();
|
||||
return isLiveCommunicating || isHeldCommunicating;
|
||||
}
|
||||
|
||||
function isSyncTarget(peerName: string) {
|
||||
return syncOnReplicationSetting
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e)
|
||||
.includes(peerName);
|
||||
}
|
||||
|
||||
async function toggleSyncTarget(peer: P2PServerInfo["knownAdvertisements"][number]) {
|
||||
if (!canEditP2PSettings()) {
|
||||
return;
|
||||
}
|
||||
const currentValue = core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "";
|
||||
const newValue = isSyncTarget(peer.name)
|
||||
? removeFromList(peer.name, currentValue)
|
||||
: addToList(peer.name, currentValue);
|
||||
await updateSelectedP2PRemote({ P2P_SyncOnReplication: newValue });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p2p-container">
|
||||
<div class="pane-header">
|
||||
<h2>P2P Status</h2>
|
||||
<div class="pane-header-actions">
|
||||
<div class="remote-picker-wrap">
|
||||
<select
|
||||
class="remote-picker"
|
||||
value={selectedP2PRemoteConfigurationId}
|
||||
onchange={onP2PRemoteSelected}
|
||||
disabled={selectingP2PRemote}
|
||||
aria-label="Select active P2P remote"
|
||||
title="Select active P2P remote"
|
||||
>
|
||||
{#if p2pRemoteOptions.length === 0}
|
||||
<option value="">Select P2P remote...</option>
|
||||
{/if}
|
||||
{#each p2pRemoteOptions as option}
|
||||
<option value={option.id}>
|
||||
{option.name}{option.roomSuffix ? ` (${option.roomSuffix})` : ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={() => createAndSelectP2PRemote()}
|
||||
title="Create P2P remote"
|
||||
aria-label="Create P2P remote"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={openConnectionSettings}
|
||||
title="Open P2P Setup..."
|
||||
aria-label="Open P2P Setup..."
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !canEditP2PSettings()}
|
||||
<p class="warning-line">Please select an active P2P remote configuration to change P2P sync targets.</p>
|
||||
{/if}
|
||||
|
||||
<P2PServerStatusCard {liveSyncReplicator} {core} />
|
||||
|
||||
<div class="peers-section">
|
||||
<div class="peers-header">
|
||||
<h3>Detected Peers</h3>
|
||||
<button class="refresh" onclick={requestServerStatus}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if serverInfo && serverInfo.knownAdvertisements.length > 0}
|
||||
<div class="peers-list">
|
||||
{#each serverInfo.knownAdvertisements as peer (peer.peerId)}
|
||||
<div class="peer-item">
|
||||
<div class="peer-info">
|
||||
<div class="peer-name">
|
||||
{peer.name} :
|
||||
<span class="peer-id-mini" title={peer.peerId}>({peer.peerId.slice(0, 8)})</span>
|
||||
{#if isCommunicating(peer.peerId)}
|
||||
<span class="comm-icon" title="Communicating" aria-label="Communicating">📡</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="peer-meta">
|
||||
<span class="badge">{peer.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peer-actions">
|
||||
{#if isAccepted(peer)}
|
||||
<div class="decision-row accepted-row">
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId ? "Replicating..." : "Replicate now"}
|
||||
aria-label={replicatingPeerId === peer.peerId ? "Replicating" : "Replicate now"}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">WATCH</span>
|
||||
<button
|
||||
class="emoji-button {isWatching(peer.peerId) ? 'is-watching' : ''}"
|
||||
title={isWatching(peer.peerId)
|
||||
? "Watching this peer \u2014 click to stop"
|
||||
: "Watch this peer's changes"}
|
||||
aria-label={isWatching(peer.peerId) ? "Stop watching" : "Watch peer"}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={() => toggleWatch(peer.peerId)}
|
||||
>
|
||||
{isWatching(peer.peerId) ? "🔔" : "🔕"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row watch-row">
|
||||
<span class="decision-label">SYNC</span>
|
||||
<button
|
||||
class="emoji-button {isSyncTarget(peer.name) ? 'is-watching' : ''}"
|
||||
title={isSyncTarget(peer.name)
|
||||
? "Sync target \u2014 click to remove"
|
||||
: "Set as sync target"}
|
||||
aria-label={isSyncTarget(peer.name) ? "Remove sync target" : "Set sync target"}
|
||||
disabled={!canEditP2PSettings()}
|
||||
onclick={() => toggleSyncTarget(peer)}
|
||||
>
|
||||
{isSyncTarget(peer.name) ? "🔗" : "⛓️💥"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="decision-status">
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">PERMANENT</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title="Allow permanently"
|
||||
aria-label="Allow permanently"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, false)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title="Deny permanently"
|
||||
aria-label="Deny permanently"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, false)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
<div class="decision-row">
|
||||
<span class="decision-label">SESSION</span>
|
||||
<button
|
||||
class="emoji-button"
|
||||
title="Allow in session"
|
||||
aria-label="Allow in session"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, true, true)}
|
||||
>
|
||||
✅
|
||||
</button>
|
||||
<button
|
||||
class="emoji-button mod-warning"
|
||||
title="Deny in session"
|
||||
aria-label="Deny in session"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => makeDecision(peer, false, true)}
|
||||
>
|
||||
🚫
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)}
|
||||
<button
|
||||
class="action-button revoke-inline"
|
||||
disabled={decidingPeerId !== null}
|
||||
onclick={() => revokeDecision(peer)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if serverInfo}
|
||||
<p class="no-peers">No devices available. Waiting for other devices to connect...</p>
|
||||
{:else}
|
||||
<p class="no-peers">Fetching status...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.p2p-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.peers-section {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pane-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-picker-wrap {
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.remote-picker {
|
||||
max-width: 10rem;
|
||||
min-width: 1em;
|
||||
flex-shrink: 1;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.warning-line {
|
||||
margin: -0.2rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.pane-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.peers-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.refresh {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.peers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.peer-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.peer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.peer-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.peer-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-chip.accepted {
|
||||
background-color: var(--background-modifier-success);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.status-chip.denied {
|
||||
background-color: var(--background-modifier-error);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.status-chip.unknown {
|
||||
background-color: var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.peer-id-mini {
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.comm-icon {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
animation: pulse-comm 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-comm {
|
||||
0% {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.peer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.decision-status {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.decision-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accepted-row {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.emoji-button {
|
||||
width: 2rem;
|
||||
height: 1.7rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.3rem;
|
||||
background-color: var(--interactive-normal);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emoji-button.mod-warning {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
|
||||
.emoji-button.is-watching {
|
||||
background-color: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.emoji-button:hover:not(:disabled) {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.emoji-button.mod-warning:hover:not(:disabled) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.watch-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.action-button:hover:not(:disabled) {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.action-button.mod-warning {
|
||||
background-color: var(--background-modifier-error);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.emoji-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.revoke-inline {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.no-peers {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -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: adcfe42522...6f977537f4
21
src/main.ts
21
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 { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
@@ -11,8 +12,6 @@ import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidian
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
||||
import { ModuleObsidianGlobalHistory } from "./modules/features/ModuleGlobalHistory.ts";
|
||||
import { ModuleIntegratedTest } from "./modules/extras/ModuleIntegratedTest.ts";
|
||||
import { ModuleReplicateTest } from "./modules/extras/ModuleReplicateTest.ts";
|
||||
import { LocalDatabaseMaintenance } from "./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts";
|
||||
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts";
|
||||
import { ObsidianServiceHub } from "./modules/services/ObsidianServiceHub.ts";
|
||||
@@ -43,6 +42,7 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/
|
||||
import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts";
|
||||
import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts";
|
||||
import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts";
|
||||
import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts";
|
||||
export type LiveSyncCore = LiveSyncBaseCore<ObsidianServiceContext, LiveSyncCommands>;
|
||||
export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
core: LiveSyncCore;
|
||||
@@ -154,8 +154,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
new ModuleInteractiveConflictResolver(this, core),
|
||||
new ModuleObsidianGlobalHistory(this, core),
|
||||
new ModuleDev(this, core),
|
||||
new ModuleReplicateTest(this, core),
|
||||
new ModuleIntegratedTest(this, core),
|
||||
new SetupManager(core), // this should be moved to core?
|
||||
new ModuleMigration(core),
|
||||
];
|
||||
@@ -175,7 +173,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const curriedFeature = () => featuresInitialiser(core);
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
|
||||
const replicator = useP2PReplicatorFeature(
|
||||
core,
|
||||
createOpenReplicationUI(this.app),
|
||||
createOpenRebuildUI(this.app)
|
||||
);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
useP2PReplicatorUI(core, core, replicator);
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
@@ -189,9 +193,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
// VIEW_TYPE_P2P,
|
||||
// (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 { AbstractModule } from "../AbstractModule";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { REMOTE_P2P, type RemoteDBSettings } from "../../lib/src/common/types";
|
||||
import type { LiveSyncAbstractReplicator } from "../../lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import { LiveSyncTrysteroReplicator } from "../../lib/src/replication/trystero/LiveSyncTrysteroReplicator";
|
||||
import type { LiveSyncCore } from "../../main";
|
||||
|
||||
// Note:
|
||||
// This module registers only the `getNewReplicator` handler for the P2P replicator.
|
||||
// `useP2PReplicator` (see P2PReplicatorCore.ts) already registers the same `getNewReplicator`
|
||||
// handler internally, so this module is redundant in environments that call `useP2PReplicator`.
|
||||
// Register this module only in environments that do NOT use `useP2PReplicator` (e.g. CLI).
|
||||
// In other words: just resolving `getNewReplicator` via this module is all that is needed
|
||||
// to satisfy what `useP2PReplicator` requires from the replicator service.
|
||||
export class ModuleReplicatorP2P extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_P2P) {
|
||||
return Promise.resolve(new LiveSyncTrysteroReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { normalizePath } from "../../deps.ts";
|
||||
import {
|
||||
FlagFilesHumanReadable,
|
||||
FlagFilesOriginal,
|
||||
REMOTE_MINIO,
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import FetchEverything from "../features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { SvelteDialogManagerBase } from "@lib/UI/svelteDialog.ts";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase.ts";
|
||||
|
||||
export class ModuleRedFlag extends AbstractModule {
|
||||
async isFlagFileExist(path: string) {
|
||||
const redflag = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (redflag) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async deleteFlagFile(path: string) {
|
||||
try {
|
||||
const isFlagged = await this.core.storageAccess.isExists(normalizePath(path));
|
||||
if (isFlagged) {
|
||||
await this.core.storageAccess.delete(path, true);
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`Could not delete ${path}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
isSuspendFlagActive = async () => await this.isFlagFileExist(FlagFilesOriginal.SUSPEND_ALL);
|
||||
isRebuildFlagActive = async () =>
|
||||
(await this.isFlagFileExist(FlagFilesOriginal.REBUILD_ALL)) ||
|
||||
(await this.isFlagFileExist(FlagFilesHumanReadable.REBUILD_ALL));
|
||||
isFetchAllFlagActive = async () =>
|
||||
(await this.isFlagFileExist(FlagFilesOriginal.FETCH_ALL)) ||
|
||||
(await this.isFlagFileExist(FlagFilesHumanReadable.FETCH_ALL));
|
||||
|
||||
async cleanupRebuildFlag() {
|
||||
await this.deleteFlagFile(FlagFilesOriginal.REBUILD_ALL);
|
||||
await this.deleteFlagFile(FlagFilesHumanReadable.REBUILD_ALL);
|
||||
}
|
||||
|
||||
async cleanupFetchAllFlag() {
|
||||
await this.deleteFlagFile(FlagFilesOriginal.FETCH_ALL);
|
||||
await this.deleteFlagFile(FlagFilesHumanReadable.FETCH_ALL);
|
||||
}
|
||||
// dialogManager = new SvelteDialogManagerBase(this.core);
|
||||
get dialogManager(): SvelteDialogManagerBase<ServiceContext> {
|
||||
return this.core.services.UI.dialogManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote if needed.
|
||||
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
*/
|
||||
async adjustSettingToRemoteIfNeeded(extra: { preventFetchingConfig: boolean }, config: ObsidianLiveSyncSettings) {
|
||||
if (extra && extra.preventFetchingConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote configuration fetched and applied.
|
||||
if (await this.adjustSettingToRemote(config)) {
|
||||
config = this.core.settings;
|
||||
} else {
|
||||
this._log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
console.debug(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote configuration.
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
* @returns updated configuration if applied, otherwise null.
|
||||
*/
|
||||
async adjustSettingToRemote(config: ObsidianLiveSyncSettings) {
|
||||
// Fetch remote configuration unless prevented.
|
||||
const SKIP_FETCH = "Skip and proceed";
|
||||
const RETRY_FETCH = "Retry (recommended)";
|
||||
let canProceed = false;
|
||||
do {
|
||||
const remoteTweaks = await this.services.tweakValue.fetchRemotePreferred(config);
|
||||
if (!remoteTweaks) {
|
||||
const choice = await this.core.confirm.askSelectStringDialogue(
|
||||
"Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.",
|
||||
[SKIP_FETCH, RETRY_FETCH] as const,
|
||||
{
|
||||
defaultAction: RETRY_FETCH,
|
||||
timeout: 0,
|
||||
title: "Fetch Remote Configuration Failed",
|
||||
}
|
||||
);
|
||||
if (choice === SKIP_FETCH) {
|
||||
canProceed = true;
|
||||
}
|
||||
} else {
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return (config as any)[key] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
this._log(
|
||||
"Remote configuration matches local configuration. No changes applied.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
} else {
|
||||
await this.core.confirm.askSelectStringDialogue(
|
||||
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
||||
["OK"] as const,
|
||||
{
|
||||
defaultAction: "OK",
|
||||
timeout: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...Object.fromEntries(differentItems),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
this.core.settings = config;
|
||||
await this.core.services.setting.saveSettingData();
|
||||
this._log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
return this.core.settings;
|
||||
}
|
||||
} while (!canProceed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process vault initialisation with suspending file watching and sync.
|
||||
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
|
||||
* @param keepSuspending whether to keep suspending file watching after the process.
|
||||
* @returns result of the process, or false if error occurs.
|
||||
*/
|
||||
async processVaultInitialisation(proc: () => Promise<boolean>, keepSuspending = false) {
|
||||
try {
|
||||
// Disable batch saving and file watching during initialisation.
|
||||
this.settings.batchSave = false;
|
||||
await this.services.setting.suspendAllSync();
|
||||
await this.services.setting.suspendExtraSync();
|
||||
this.settings.suspendFileWatching = true;
|
||||
await this.saveSettings();
|
||||
try {
|
||||
const result = await proc();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
this._log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log("Error during vault initialisation.", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
} finally {
|
||||
if (!keepSuspending) {
|
||||
// Re-enable file watching after initialisation.
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the rebuild everything scheduled operation.
|
||||
* @returns true if can be continued, false if app restart is needed.
|
||||
*/
|
||||
async onRebuildEverythingScheduled() {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(RebuildEverything);
|
||||
if (method === "cancelled") {
|
||||
// Clean up the flag file and restart the app.
|
||||
this._log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await this.cleanupRebuildFlag();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { extra } = method;
|
||||
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
await this.core.rebuilder.$rebuildEverything();
|
||||
await this.cleanupRebuildFlag();
|
||||
this._log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Handle the fetch all scheduled operation.
|
||||
* @returns true if can be continued, false if app restart is needed.
|
||||
*/
|
||||
async onFetchAllScheduled() {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(FetchEverything);
|
||||
if (method === "cancelled") {
|
||||
this._log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
// Clean up the flag file and restart the app.
|
||||
await this.cleanupFetchAllFlag();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { vault, extra } = method;
|
||||
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
|
||||
const makeLocalChunkBeforeSyncAvailable = this.settings.remoteType !== REMOTE_MINIO;
|
||||
const mapVaultStateToAction = {
|
||||
identical: {
|
||||
// If both are identical, no need to make local files/chunks before sync,
|
||||
// Just for the efficiency, chunks should be made before sync.
|
||||
makeLocalChunkBeforeSync: makeLocalChunkBeforeSyncAvailable,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
independent: {
|
||||
// If both are independent, nothing needs to be made before sync.
|
||||
// Respect the remote state.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
unbalanced: {
|
||||
// If both are unbalanced, local files should be made before sync to avoid data loss.
|
||||
// Then, chunks should be made before sync for the efficiency, but also the metadata made and should be detected as conflicting.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: true,
|
||||
},
|
||||
cancelled: {
|
||||
// Cancelled case, not actually used.
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
await this.adjustSettingToRemoteIfNeeded(extra, this.settings);
|
||||
// Okay, proceed to fetch everything.
|
||||
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = mapVaultStateToAction[vault];
|
||||
this._log(
|
||||
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await this.core.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
|
||||
await this.cleanupFetchAllFlag();
|
||||
this._log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async onSuspendAllScheduled() {
|
||||
this._log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE);
|
||||
return await this.processVaultInitialisation(async () => {
|
||||
this._log(
|
||||
"All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.settings.writeLogToTheFile = true;
|
||||
await this.core.services.setting.saveSettingData();
|
||||
return Promise.resolve(false);
|
||||
}, true);
|
||||
}
|
||||
|
||||
async verifyAndUnlockSuspension() {
|
||||
if (!this.settings.suspendFileWatching) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) != "yes"
|
||||
) {
|
||||
// TODO: Confirm actually proceed to next process.
|
||||
return true;
|
||||
}
|
||||
this.settings.suspendFileWatching = false;
|
||||
await this.saveSettings();
|
||||
this.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
|
||||
private async processFlagFilesOnStartup(): Promise<boolean> {
|
||||
const isFlagSuspensionActive = await this.isSuspendFlagActive();
|
||||
const isFlagRebuildActive = await this.isRebuildFlagActive();
|
||||
const isFlagFetchAllActive = await this.isFetchAllFlagActive();
|
||||
// TODO: Address the case when both flags are active (very unlikely though).
|
||||
// if(isFlagFetchAllActive && isFlagRebuildActive) {
|
||||
// const message = "Rebuild everything and Fetch everything flags are both detected.";
|
||||
// await this.core.confirm.askSelectStringDialogue(
|
||||
// "Both Rebuild Everything and Fetch Everything flags are detected. Please remove one of them and restart the app.",
|
||||
// ["OK"] as const,)
|
||||
if (isFlagFetchAllActive) {
|
||||
const res = await this.onFetchAllScheduled();
|
||||
if (res) {
|
||||
return await this.verifyAndUnlockSuspension();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isFlagRebuildActive) {
|
||||
const res = await this.onRebuildEverythingScheduled();
|
||||
if (res) {
|
||||
return await this.verifyAndUnlockSuspension();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isFlagSuspensionActive) {
|
||||
const res = await this.onSuspendAllScheduled();
|
||||
return res;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async _everyOnLayoutReady(): Promise<boolean> {
|
||||
try {
|
||||
const flagProcessResult = await this.processFlagFilesOnStartup();
|
||||
return flagProcessResult;
|
||||
} catch (ex) {
|
||||
this._log("Something went wrong on FlagFile Handling", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
TweakValuesTemplate,
|
||||
IncompatibleChanges,
|
||||
confName,
|
||||
type TweakValues,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type RemoteDBSettings,
|
||||
IncompatibleChangesInSpecificPattern,
|
||||
CompatibleButLossyChanges,
|
||||
@@ -14,8 +16,107 @@ import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { $msg } from "../../lib/src/common/i18n.ts";
|
||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { REMOTE_P2P } from "@lib/common/models/setting.const.ts";
|
||||
|
||||
function valueToString(value: any) {
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private _hasNotifiedAutoAcceptCompatibleUndefined = false;
|
||||
|
||||
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
|
||||
const items = Object.keys(
|
||||
TweakValuesShouldMatchedTemplate
|
||||
) as (keyof typeof TweakValuesShouldMatchedTemplate)[];
|
||||
return items.filter((key) => current[key] !== preferred[key]);
|
||||
}
|
||||
|
||||
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
|
||||
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
|
||||
const currentModified = current.tweakModified;
|
||||
const preferredModified = preferred.tweakModified;
|
||||
// debugger;
|
||||
const hasCurrentModified = typeof currentModified === "number" && currentModified > 0;
|
||||
const hasPreferredModified = typeof preferredModified === "number" && preferredModified > 0;
|
||||
|
||||
if (!hasCurrentModified && !hasPreferredModified) return "REMOTE";
|
||||
if (!hasCurrentModified) return "REMOTE";
|
||||
if (!hasPreferredModified) return "CURRENT";
|
||||
if (preferredModified >= currentModified) return "REMOTE";
|
||||
return "CURRENT";
|
||||
}
|
||||
|
||||
private async _shouldAutoAcceptCompatibleLossy(
|
||||
current: TweakValues,
|
||||
preferred: Partial<TweakValues>,
|
||||
mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
|
||||
): Promise<"REMOTE" | "CURRENT" | undefined> {
|
||||
if (mismatchedKeys.length === 0) return undefined;
|
||||
const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
|
||||
(key) => CompatibleButLossyChanges.indexOf(key) !== -1
|
||||
);
|
||||
if (!hasOnlyCompatibleLossyMismatches) return undefined;
|
||||
|
||||
if (this.settings.autoAcceptCompatibleTweak === undefined) {
|
||||
if (this._hasNotifiedAutoAcceptCompatibleUndefined) {
|
||||
return undefined;
|
||||
}
|
||||
this._hasNotifiedAutoAcceptCompatibleUndefined = true;
|
||||
const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible");
|
||||
const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible");
|
||||
const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const;
|
||||
const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
|
||||
title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"),
|
||||
timeout: 0,
|
||||
defaultAction: CHOICE_ENABLE,
|
||||
});
|
||||
if (ret !== CHOICE_ENABLE) {
|
||||
return undefined;
|
||||
}
|
||||
await this.services.setting.applyPartial(
|
||||
{
|
||||
autoAcceptCompatibleTweak: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
Logger("Auto-accept for compatible tweak mismatch has been enabled.");
|
||||
}
|
||||
|
||||
if (this.settings.autoAcceptCompatibleTweak !== true) return undefined;
|
||||
return this._selectNewerTweakSide(current, preferred);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook before saving settings, to check if there are changes in tweak values, and if so,
|
||||
* update the tweakModified timestamp to current time.
|
||||
* This allows other devices to know that the tweak values have been changed and decide whether to accept the new values based on the modification time.
|
||||
* @param next
|
||||
* @param previous
|
||||
* @returns
|
||||
*/
|
||||
async _onBeforeSaveSettingData(next: ObsidianLiveSyncSettings, previous: ObsidianLiveSyncSettings) {
|
||||
const tweakKeys = Object.keys(TweakValuesTemplate) as (keyof TweakValues)[];
|
||||
const tweakKeysForUpdate = tweakKeys.filter((key) => key !== "tweakModified");
|
||||
const hasChangedTweak = tweakKeysForUpdate.some((key) => next[key] !== previous[key]);
|
||||
if (!hasChangedTweak) return;
|
||||
Logger(
|
||||
`Some tweak values have been changed. ${tweakKeysForUpdate.filter((key) => next[key] !== previous[key]).join(", ")}`
|
||||
);
|
||||
const modified = Date.now();
|
||||
Logger(`Modified: ${modified}`);
|
||||
return await Promise.resolve({
|
||||
tweakModified: modified,
|
||||
});
|
||||
}
|
||||
|
||||
async _anyAfterConnectCheckFailed(): Promise<boolean | "CHECKAGAIN" | undefined> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false;
|
||||
const preferred = this.core.replicator.preferredTweakValue;
|
||||
@@ -26,10 +127,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (ret == "IGNORE") return true;
|
||||
}
|
||||
|
||||
async _checkAndAskResolvingMismatchedTweaks(
|
||||
preferred: Partial<TweakValues>
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings);
|
||||
async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
|
||||
const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
|
||||
const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys);
|
||||
if (autoAcceptSide === "REMOTE") {
|
||||
return [{ ...mine, ...preferred }, false];
|
||||
}
|
||||
if (autoAcceptSide === "CURRENT") {
|
||||
return [true, false];
|
||||
}
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
@@ -68,8 +175,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: confName(key),
|
||||
self: valueMine,
|
||||
remote: valuePreferred,
|
||||
self: valueToString(valueMine),
|
||||
remote: valueToString(valuePreferred),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -136,9 +243,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (!tweaks) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const preferred = extractObject(TweakValuesShouldMatchedTemplate, tweaks);
|
||||
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferred);
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks);
|
||||
if (!conf) return "IGNORE";
|
||||
|
||||
if (conf === true) {
|
||||
@@ -146,10 +251,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
Logger(
|
||||
`Tweak values on the remote server have been updated. Your other device will see this message.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
if (conf) {
|
||||
@@ -159,7 +261,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
Logger(`Configuration has been updated as configured by the other device.`, LOG_LEVEL_NOTICE);
|
||||
Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE);
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
return "IGNORE";
|
||||
@@ -186,6 +288,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
async _checkAndAskUseRemoteConfiguration(
|
||||
trialSetting: RemoteDBSettings
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
if (trialSetting.remoteType === REMOTE_P2P) {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
|
||||
if (preferred) {
|
||||
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred);
|
||||
@@ -197,6 +302,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
trialSetting: RemoteDBSettings,
|
||||
preferred: TweakValues
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
|
||||
const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys);
|
||||
if (autoAcceptSide === "REMOTE") {
|
||||
return { result: { ...trialSetting, ...preferred }, requireFetch: false };
|
||||
}
|
||||
if (autoAcceptSide === "CURRENT") {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
@@ -207,8 +322,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const remoteValueForDisplay = escapeMarkdownValue(preferred[key]);
|
||||
const currentValueForDisplay = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])}`;
|
||||
const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key]));
|
||||
const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key]));
|
||||
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
|
||||
if (IncompatibleChanges.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
@@ -285,6 +400,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.setting.onBeforeSaveSettingData.addHandler(this._onBeforeSaveSettingData.bind(this));
|
||||
services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this));
|
||||
services.tweakValue.checkAndAskResolvingMismatched.setHandler(
|
||||
this._checkAndAskResolvingMismatchedTweaks.bind(this)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@lib/common/types";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
setting: {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
...settingsOverride,
|
||||
},
|
||||
confirm: {
|
||||
askSelectStringDialogue,
|
||||
},
|
||||
} as any;
|
||||
|
||||
Object.defineProperty(core, "services", {
|
||||
get() {
|
||||
return core._services;
|
||||
},
|
||||
});
|
||||
|
||||
const module = new ModuleResolvingMismatchedTweaks(core);
|
||||
return { module, core, askSelectStringDialogue };
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 100,
|
||||
});
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toEqual(preferred);
|
||||
expect(rebuild).toBe(false);
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
encrypt: false,
|
||||
tweakModified: 100,
|
||||
});
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
encrypt: true,
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
|
||||
const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(conf).toBe(false);
|
||||
expect(rebuild).toBe(false);
|
||||
expect(askSelectStringDialogue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 300,
|
||||
});
|
||||
|
||||
const trialSetting = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 300,
|
||||
} as RemoteDBSettings;
|
||||
|
||||
const preferred = {
|
||||
...(trialSetting as unknown as TweakValues),
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as TweakValues;
|
||||
|
||||
const result = await module._askUseRemoteConfiguration(trialSetting, preferred);
|
||||
|
||||
expect(result).toEqual({ result: false, requireFetch: false });
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ButtonComponent } from "@/deps.ts";
|
||||
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "../../../deps.ts";
|
||||
import { EVENT_PLUGIN_UNLOADED, eventHub } from "../../../common/events.ts";
|
||||
import { compatGlobal, type CompatIntervalHandle } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
class AutoClosableModal extends Modal {
|
||||
_closeByUnload() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
this.close();
|
||||
}
|
||||
@@ -11,9 +13,11 @@ class AutoClosableModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
this._closeByUnload = this._closeByUnload.bind(this);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
eventHub.once(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
override onClose() {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +125,7 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
this.callback = undefined;
|
||||
}
|
||||
override onClose(): void {
|
||||
setTimeout(() => {
|
||||
compatGlobal.setTimeout(() => {
|
||||
if (this.callback) {
|
||||
this.callback("");
|
||||
this.callback = undefined;
|
||||
@@ -139,7 +143,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
isManuallyClosed = false;
|
||||
defaultAction: string | undefined;
|
||||
timeout: number | undefined;
|
||||
timer: ReturnType<typeof setInterval> | undefined = undefined;
|
||||
timer: CompatIntervalHandle | undefined = undefined;
|
||||
defaultButtonComponent: ButtonComponent | undefined;
|
||||
wideButton: boolean;
|
||||
|
||||
@@ -165,12 +169,12 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
this.timeout = timeout;
|
||||
this.wideButton = wideButton;
|
||||
if (this.timeout) {
|
||||
this.timer = setInterval(() => {
|
||||
this.timer = compatGlobal.setInterval(() => {
|
||||
if (this.timeout === undefined) return;
|
||||
this.timeout--;
|
||||
if (this.timeout < 0) {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
compatGlobal.clearInterval(this.timer);
|
||||
this.defaultButtonComponent?.setButtonText(`${defaultAction}`);
|
||||
this.timer = undefined;
|
||||
}
|
||||
@@ -213,7 +217,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
if (this.timer) {
|
||||
labelWrapper.empty();
|
||||
labelWrapper.style.display = "none";
|
||||
clearInterval(this.timer);
|
||||
compatGlobal.clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
this.defaultButtonComponent?.setButtonText(`${this.defaultAction}`);
|
||||
}
|
||||
@@ -224,7 +228,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
this.isManuallyClosed = true;
|
||||
this.result = button;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
compatGlobal.clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
this.close();
|
||||
@@ -247,7 +251,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
compatGlobal.clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
if (this.isManuallyClosed) {
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { throttle } from "octagonal-wheels/function";
|
||||
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts";
|
||||
import { BASE_IS_NEW, EVEN, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts";
|
||||
import {
|
||||
type FilePathWithPrefixLC,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
isMetaEntry,
|
||||
type EntryDoc,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
type UXFileInfoStub,
|
||||
type LOG_LEVEL,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { isAnyNote } from "../../lib/src/common/utils.ts";
|
||||
import { stripAllPrefixes } from "../../lib/src/string_and_binary/path.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { withConcurrency } from "octagonal-wheels/iterable/map";
|
||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
export class ModuleInitializerFile extends AbstractModule {
|
||||
private _detectedErrors = new Set<string>();
|
||||
|
||||
private logDetectedError(message: string, logLevel: LOG_LEVEL = LOG_LEVEL_INFO, key?: string) {
|
||||
this._detectedErrors.add(message);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._log(message, logLevel, key);
|
||||
}
|
||||
private resetDetectedError(message: string) {
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
this._detectedErrors.delete(message);
|
||||
}
|
||||
private async _performFullScan(showingNotice?: boolean, ignoreSuspending: boolean = false): Promise<boolean> {
|
||||
this._log("Opening the key-value database", LOG_LEVEL_VERBOSE);
|
||||
const isInitialized = (await this.core.kvDB.get<boolean>("initialized")) || false;
|
||||
// synchronize all files between database and storage.
|
||||
|
||||
const ERR_NOT_CONFIGURED =
|
||||
"LiveSync is not configured yet. Synchronising between the storage and the local database is now prevented.";
|
||||
if (!this.settings.isConfigured) {
|
||||
this.logDetectedError(ERR_NOT_CONFIGURED, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
|
||||
return false;
|
||||
}
|
||||
this.resetDetectedError(ERR_NOT_CONFIGURED);
|
||||
|
||||
const ERR_SUSPENDING =
|
||||
"Now suspending file watching. Synchronising between the storage and the local database is now prevented.";
|
||||
if (!ignoreSuspending && this.settings.suspendFileWatching) {
|
||||
this.logDetectedError(ERR_SUSPENDING, showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO, "syncAll");
|
||||
return false;
|
||||
}
|
||||
const MSG_IN_REMEDIATION = `Started in remediation Mode! (Max mtime for reflect events is set). Synchronising between the storage and the local database is now prevented.`;
|
||||
this.resetDetectedError(ERR_SUSPENDING);
|
||||
if (this.settings.maxMTimeForReflectEvents > 0) {
|
||||
this.logDetectedError(MSG_IN_REMEDIATION, LOG_LEVEL_NOTICE, "syncAll");
|
||||
return false;
|
||||
}
|
||||
this.resetDetectedError(MSG_IN_REMEDIATION);
|
||||
|
||||
if (showingNotice) {
|
||||
this._log("Initializing", LOG_LEVEL_NOTICE, "syncAll");
|
||||
}
|
||||
if (isInitialized) {
|
||||
this._log("Restoring storage state", LOG_LEVEL_VERBOSE);
|
||||
await this.core.storageAccess.restoreState();
|
||||
}
|
||||
|
||||
this._log("Initialize and checking database files");
|
||||
this._log("Checking deleted files");
|
||||
await this.collectDeletedFiles();
|
||||
|
||||
this._log("Collecting local files on the storage", LOG_LEVEL_VERBOSE);
|
||||
const filesStorageSrc = await this.core.storageAccess.getFiles();
|
||||
|
||||
const _filesStorage = [] as typeof filesStorageSrc;
|
||||
|
||||
for (const f of filesStorageSrc) {
|
||||
if (await this.services.vault.isTargetFile(f.path)) {
|
||||
_filesStorage.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
const convertCase = <FilePathWithPrefix>(path: FilePathWithPrefix): FilePathWithPrefixLC => {
|
||||
if (this.settings.handleFilenameCaseSensitive) {
|
||||
return path as FilePathWithPrefixLC;
|
||||
}
|
||||
return (path as string).toLowerCase() as FilePathWithPrefixLC;
|
||||
};
|
||||
|
||||
// If handleFilenameCaseSensitive is enabled, `FilePathWithPrefixLC` is the same as `FilePathWithPrefix`.
|
||||
|
||||
const storageFileNameMap = Object.fromEntries(
|
||||
_filesStorage.map((e) => [e.path, e] as [FilePathWithPrefix, UXFileInfoStub])
|
||||
);
|
||||
|
||||
const storageFileNames = Object.keys(storageFileNameMap) as FilePathWithPrefix[];
|
||||
|
||||
const storageFileNameCapsPair = storageFileNames.map(
|
||||
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
|
||||
);
|
||||
|
||||
// const storageFileNameCS2CI = Object.fromEntries(storageFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const storageFileNameCI2CS = Object.fromEntries(storageFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
|
||||
FilePathWithPrefixLC,
|
||||
FilePathWithPrefix
|
||||
>;
|
||||
|
||||
this._log("Collecting local files on the DB", LOG_LEVEL_VERBOSE);
|
||||
const _DBEntries = [] as MetaEntry[];
|
||||
let count = 0;
|
||||
// Fetch all documents from the database (including conflicts to prevent overwriting).
|
||||
for await (const doc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
count++;
|
||||
if (count % 25 == 0)
|
||||
this._log(
|
||||
`Collecting local files on the DB: ${count}`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"syncAll"
|
||||
);
|
||||
const path = this.getPath(doc);
|
||||
|
||||
if (isValidPath(path) && (await this.services.vault.isTargetFile(path))) {
|
||||
if (!isMetaEntry(doc)) {
|
||||
this._log(`Invalid entry: ${path}`, LOG_LEVEL_INFO);
|
||||
continue;
|
||||
}
|
||||
_DBEntries.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
const databaseFileNameMap = Object.fromEntries(
|
||||
_DBEntries.map((e) => [this.getPath(e), e] as [FilePathWithPrefix, MetaEntry])
|
||||
);
|
||||
const databaseFileNames = Object.keys(databaseFileNameMap) as FilePathWithPrefix[];
|
||||
const databaseFileNameCapsPair = databaseFileNames.map(
|
||||
(e) => [e, convertCase(e)] as [FilePathWithPrefix, FilePathWithPrefixLC]
|
||||
);
|
||||
// const databaseFileNameCS2CI = Object.fromEntries(databaseFileNameCapsPair) as Record<FilePathWithPrefix, FilePathWithPrefixLC>;
|
||||
const databaseFileNameCI2CS = Object.fromEntries(databaseFileNameCapsPair.map((e) => [e[1], e[0]])) as Record<
|
||||
FilePathWithPrefix,
|
||||
FilePathWithPrefixLC
|
||||
>;
|
||||
|
||||
const allFiles = unique([
|
||||
...Object.keys(databaseFileNameCI2CS),
|
||||
...Object.keys(storageFileNameCI2CS),
|
||||
]) as FilePathWithPrefixLC[];
|
||||
|
||||
this._log(`Total files in the database: ${databaseFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Total files in the storage: ${storageFileNames.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Total files: ${allFiles.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
const filesExistOnlyInStorage = allFiles.filter((e) => !databaseFileNameCI2CS[e]);
|
||||
const filesExistOnlyInDatabase = allFiles.filter((e) => !storageFileNameCI2CS[e]);
|
||||
const filesExistBoth = allFiles.filter((e) => databaseFileNameCI2CS[e] && storageFileNameCI2CS[e]);
|
||||
|
||||
this._log(`Files exist only in storage: ${filesExistOnlyInStorage.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Files exist only in database: ${filesExistOnlyInDatabase.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
this._log(`Files exist both in storage and database: ${filesExistBoth.length}`, LOG_LEVEL_VERBOSE, "syncAll");
|
||||
|
||||
this._log("Synchronising...");
|
||||
const processStatus = {} as Record<string, string>;
|
||||
const logLevel = showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
const updateLog = throttle((key: string, msg: string) => {
|
||||
processStatus[key] = msg;
|
||||
const log = Object.values(processStatus).join("\n");
|
||||
this._log(log, logLevel, "syncAll");
|
||||
}, 25);
|
||||
|
||||
const initProcess = [];
|
||||
const runAll = async <T>(procedureName: string, objects: T[], callback: (arg: T) => Promise<void>) => {
|
||||
if (objects.length == 0) {
|
||||
this._log(`${procedureName}: Nothing to do`);
|
||||
return;
|
||||
}
|
||||
this._log(procedureName);
|
||||
if (!this.localDatabase.isReady) throw Error("Database is not ready!");
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let total = 0;
|
||||
for await (const result of withConcurrency(
|
||||
objects,
|
||||
async (e) => {
|
||||
try {
|
||||
await callback(e);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`Error while ${procedureName}`, LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
10
|
||||
)) {
|
||||
if (result) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
total++;
|
||||
const msg = `${procedureName}: DONE:${success}, FAILED:${failed}, LAST:${objects.length - total}`;
|
||||
updateLog(procedureName, msg);
|
||||
}
|
||||
const msg = `${procedureName} All done: DONE:${success}, FAILED:${failed}`;
|
||||
updateLog(procedureName, msg);
|
||||
};
|
||||
initProcess.push(
|
||||
runAll("UPDATE DATABASE", filesExistOnlyInStorage, async (e) => {
|
||||
// Exists in storage but not in database.
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[e]];
|
||||
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
||||
const path = file.path;
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(path, true));
|
||||
eventHub.emitEvent("event-file-changed", { file: path, automated: true });
|
||||
} else {
|
||||
this._log(`UPDATE DATABASE: ${e} has been skipped due to file size exceeding the limit`, logLevel);
|
||||
}
|
||||
})
|
||||
);
|
||||
initProcess.push(
|
||||
runAll("UPDATE STORAGE", filesExistOnlyInDatabase, async (e) => {
|
||||
const w = databaseFileNameMap[databaseFileNameCI2CS[e]];
|
||||
// Exists in database but not in storage.
|
||||
const path = this.getPath(w) ?? e;
|
||||
if (w && !(w.deleted || w._deleted)) {
|
||||
if (!this.services.vault.isFileSizeTooLarge(w.size)) {
|
||||
// Prevent applying the conflicted state to the storage.
|
||||
if (w._conflicts?.length ?? 0 > 0) {
|
||||
this._log(`UPDATE STORAGE: ${path} has conflicts. skipped (x)`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
// await this.pullFile(path, undefined, false, undefined, false);
|
||||
// Memo: No need to force
|
||||
await this.core.fileHandler.dbToStorage(path, null, true);
|
||||
// fireAndForget(() => this.checkAndApplySettingFromMarkdown(e, true));
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: e,
|
||||
automated: true,
|
||||
});
|
||||
this._log(`Check or pull from db:${path} OK`);
|
||||
} else {
|
||||
this._log(
|
||||
`UPDATE STORAGE: ${path} has been skipped due to file size exceeding the limit`,
|
||||
logLevel
|
||||
);
|
||||
}
|
||||
} else if (w) {
|
||||
this._log(`Deletion history skipped: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
} else {
|
||||
this._log(`entry not found: ${path}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const fileMap = filesExistBoth.map((path) => {
|
||||
const file = storageFileNameMap[storageFileNameCI2CS[path]];
|
||||
const doc = databaseFileNameMap[databaseFileNameCI2CS[path]];
|
||||
return { file, doc };
|
||||
});
|
||||
initProcess.push(
|
||||
runAll("SYNC DATABASE AND STORAGE", fileMap, async (e) => {
|
||||
const { file, doc } = e;
|
||||
// Prevent applying the conflicted state to the storage.
|
||||
if (doc._conflicts?.length ?? 0 > 0) {
|
||||
this._log(`SYNC DATABASE AND STORAGE: ${file.path} has conflicts. skipped`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.services.vault.isFileSizeTooLarge(file.stat.size) &&
|
||||
!this.services.vault.isFileSizeTooLarge(doc.size)
|
||||
) {
|
||||
await this.syncFileBetweenDBandStorage(file, doc);
|
||||
} else {
|
||||
this._log(
|
||||
`SYNC DATABASE AND STORAGE: ${this.getPath(doc)} has been skipped due to file size exceeding the limit`,
|
||||
logLevel
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(initProcess);
|
||||
|
||||
// this.setStatusBarText(`NOW TRACKING!`);
|
||||
this._log("Initialized, NOW TRACKING!");
|
||||
if (!isInitialized) {
|
||||
await this.core.kvDB.set("initialized", true);
|
||||
}
|
||||
if (showingNotice) {
|
||||
this._log("Initialize done!", LOG_LEVEL_NOTICE, "syncAll");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async syncFileBetweenDBandStorage(file: UXFileInfoStub, doc: MetaEntry) {
|
||||
if (!doc) {
|
||||
throw new Error(`Missing doc:${(file as any).path}`);
|
||||
}
|
||||
if ("path" in file) {
|
||||
const w = await this.core.storageAccess.getFileStub((file as any).path);
|
||||
if (w) {
|
||||
file = w;
|
||||
} else {
|
||||
throw new Error(`Missing file:${(file as any).path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const compareResult = this.services.path.compareFileFreshness(file, doc);
|
||||
switch (compareResult) {
|
||||
case BASE_IS_NEW:
|
||||
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
||||
this._log("STORAGE -> DB :" + file.path);
|
||||
await this.core.fileHandler.storeFileToDB(file);
|
||||
} else {
|
||||
this._log(
|
||||
`STORAGE -> DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
break;
|
||||
case TARGET_IS_NEW:
|
||||
if (!this.services.vault.isFileSizeTooLarge(doc.size)) {
|
||||
this._log("STORAGE <- DB :" + file.path);
|
||||
if (await this.core.fileHandler.dbToStorage(doc, stripAllPrefixes(file.path), true)) {
|
||||
eventHub.emitEvent("event-file-changed", {
|
||||
file: file.path,
|
||||
automated: true,
|
||||
});
|
||||
} else {
|
||||
this._log(`STORAGE <- DB : Cloud not read ${file.path}, possibly deleted`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return caches;
|
||||
} else {
|
||||
this._log(
|
||||
`STORAGE <- DB : ${file.path} has been skipped due to file size exceeding the limit`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
break;
|
||||
case EVEN:
|
||||
this._log("STORAGE == DB :" + file.path + "", LOG_LEVEL_DEBUG);
|
||||
break;
|
||||
default:
|
||||
this._log("STORAGE ?? DB :" + file.path + " Something got weird");
|
||||
}
|
||||
}
|
||||
|
||||
// This method uses an old version of database accessor, which is not recommended.
|
||||
// TODO: Fix
|
||||
async collectDeletedFiles() {
|
||||
const limitDays = this.settings.automaticallyDeleteMetadataOfDeletedFiles;
|
||||
if (limitDays <= 0) return;
|
||||
this._log(`Checking expired file history`);
|
||||
const limit = Date.now() - 86400 * 1000 * limitDays;
|
||||
const notes: {
|
||||
path: string;
|
||||
mtime: number;
|
||||
ttl: number;
|
||||
doc: PouchDB.Core.ExistingDocument<EntryDoc & PouchDB.Core.AllDocsMeta>;
|
||||
}[] = [];
|
||||
for await (const doc of this.localDatabase.findAllDocs({ conflicts: true })) {
|
||||
if (isAnyNote(doc)) {
|
||||
if (doc.deleted && doc.mtime - limit < 0) {
|
||||
notes.push({
|
||||
path: this.getPath(doc),
|
||||
mtime: doc.mtime,
|
||||
ttl: (doc.mtime - limit) / 1000 / 86400,
|
||||
doc: doc,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notes.length == 0) {
|
||||
this._log("There are no old documents");
|
||||
this._log(`Checking expired file history done`);
|
||||
return;
|
||||
}
|
||||
for (const v of notes) {
|
||||
this._log(`Deletion history expired: ${v.path}`);
|
||||
const delDoc = v.doc;
|
||||
delDoc._deleted = true;
|
||||
await this.localDatabase.putRaw(delDoc);
|
||||
}
|
||||
this._log(`Checking expired file history done`);
|
||||
}
|
||||
|
||||
private async _initializeDatabase(
|
||||
showingNotice: boolean = false,
|
||||
reopenDatabase = true,
|
||||
ignoreSuspending: boolean = false
|
||||
): Promise<boolean> {
|
||||
this.services.appLifecycle.resetIsReady();
|
||||
if (
|
||||
!reopenDatabase ||
|
||||
(await this.services.database.openDatabase({
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
replicator: this.services.replicator,
|
||||
}))
|
||||
) {
|
||||
if (this.localDatabase.isReady) {
|
||||
await this.services.vault.scanVault(showingNotice, ignoreSuspending);
|
||||
}
|
||||
const ERR_INITIALISATION_FAILED = `Initializing database has been failed on some module!`;
|
||||
if (!(await this.services.databaseEvents.onDatabaseInitialised(showingNotice))) {
|
||||
this.logDetectedError(ERR_INITIALISATION_FAILED, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this.resetDetectedError(ERR_INITIALISATION_FAILED);
|
||||
this.services.appLifecycle.markIsReady();
|
||||
// run queued event once.
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
return true;
|
||||
} else {
|
||||
this.services.appLifecycle.resetIsReady();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private _reportDetectedErrors(): Promise<string[]> {
|
||||
return Promise.resolve(Array.from(this._detectedErrors));
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this._reportDetectedErrors.bind(this));
|
||||
services.databaseEvents.initialiseDatabase.addHandler(this._initializeDatabase.bind(this));
|
||||
services.vault.scanVault.addHandler(this._performFullScan.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ export class ObsHttpHandler extends FetchHttpHandler {
|
||||
this.requestTimeoutInMs = options === undefined ? undefined : options.requestTimeout;
|
||||
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||
}
|
||||
// eslint-disable-next-line require-await
|
||||
override async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { sizeToHumanReadable } from "octagonal-wheels/number";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { EVENT_REQUEST_CHECK_REMOTE_SIZE, eventHub } from "@/common/events.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
|
||||
export class ModuleCheckRemoteSize extends AbstractModule {
|
||||
checkRemoteSize(): Promise<boolean> {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 1;
|
||||
return this._allScanStat();
|
||||
}
|
||||
|
||||
private async _allScanStat(): Promise<boolean> {
|
||||
if (this.services.API.isOnline === false) {
|
||||
this._log("Network is offline, skipping remote size check.", LOG_LEVEL_INFO);
|
||||
return true;
|
||||
}
|
||||
this._log($msg("moduleCheckRemoteSize.logCheckingStorageSizes"), LOG_LEVEL_VERBOSE);
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize < 0) {
|
||||
const message = $msg("moduleCheckRemoteSize.msgSetDBCapacity");
|
||||
const ANSWER_0 = $msg("moduleCheckRemoteSize.optionNoWarn");
|
||||
const ANSWER_800 = $msg("moduleCheckRemoteSize.option800MB");
|
||||
const ANSWER_2000 = $msg("moduleCheckRemoteSize.option2GB");
|
||||
const ASK_ME_NEXT_TIME = $msg("moduleCheckRemoteSize.optionAskMeLater");
|
||||
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_0, ANSWER_800, ANSWER_2000, ASK_ME_NEXT_TIME],
|
||||
{
|
||||
defaultAction: ASK_ME_NEXT_TIME,
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeNotify"),
|
||||
timeout: 40,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_0) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 0;
|
||||
await this.saveSettings();
|
||||
} else if (ret == ANSWER_800) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 800;
|
||||
await this.saveSettings();
|
||||
} else if (ret == ANSWER_2000) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = 2000;
|
||||
await this.saveSettings();
|
||||
}
|
||||
}
|
||||
if (this.settings.notifyThresholdOfRemoteStorageSize > 0) {
|
||||
const remoteStat = await this.core.replicator?.getRemoteStatus(this.settings);
|
||||
if (remoteStat) {
|
||||
const estimatedSize = remoteStat.estimatedSize;
|
||||
if (estimatedSize) {
|
||||
const maxSize = this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024;
|
||||
if (estimatedSize > maxSize) {
|
||||
const message = $msg("moduleCheckRemoteSize.msgDatabaseGrowing", {
|
||||
estimatedSize: sizeToHumanReadable(estimatedSize),
|
||||
maxSize: sizeToHumanReadable(maxSize),
|
||||
});
|
||||
const newMax = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
const ANSWER_ENLARGE_LIMIT = $msg("moduleCheckRemoteSize.optionIncreaseLimit", {
|
||||
newMax: newMax.toString(),
|
||||
});
|
||||
const ANSWER_REBUILD = $msg("moduleCheckRemoteSize.optionRebuildAll");
|
||||
const ANSWER_IGNORE = $msg("moduleCheckRemoteSize.optionDismiss");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[ANSWER_ENLARGE_LIMIT, ANSWER_REBUILD, ANSWER_IGNORE],
|
||||
{
|
||||
defaultAction: ANSWER_IGNORE,
|
||||
title: $msg("moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded"),
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == ANSWER_REBUILD) {
|
||||
const ret = await this.core.confirm.askYesNoDialog(
|
||||
$msg("moduleCheckRemoteSize.msgConfirmRebuild"),
|
||||
{ defaultOption: "No" }
|
||||
);
|
||||
if (ret == "yes") {
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = -1;
|
||||
await this.saveSettings();
|
||||
await this.core.rebuilder.scheduleRebuild();
|
||||
}
|
||||
} else if (ret == ANSWER_ENLARGE_LIMIT) {
|
||||
this.settings.notifyThresholdOfRemoteStorageSize = ~~(estimatedSize / 1024 / 1024) + 100;
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logThresholdEnlarged", {
|
||||
size: this.settings.notifyThresholdOfRemoteStorageSize.toString(),
|
||||
}),
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
// await this.core.saveSettings();
|
||||
await this.core.services.setting.saveSettingData();
|
||||
} else {
|
||||
// Dismiss or Close the dialog
|
||||
}
|
||||
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logExceededWarning", {
|
||||
measuredSize: sizeToHumanReadable(estimatedSize),
|
||||
notifySize: sizeToHumanReadable(
|
||||
this.settings.notifyThresholdOfRemoteStorageSize * 1024 * 1024
|
||||
),
|
||||
}),
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
this._log(
|
||||
$msg("moduleCheckRemoteSize.logCurrentStorageSize", {
|
||||
measuredSize: sizeToHumanReadable(estimatedSize),
|
||||
}),
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _everyOnloadStart(): Promise<boolean> {
|
||||
this.addCommand({
|
||||
id: "livesync-reset-remote-size-threshold-and-check",
|
||||
name: "Reset notification threshold and check the remote database usage",
|
||||
callback: async () => {
|
||||
await this.checkRemoteSize();
|
||||
},
|
||||
});
|
||||
eventHub.onEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE, () => this.checkRemoteSize());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
hiddenFilesProcessingCount,
|
||||
} from "../../lib/src/mock_and_interop/stores.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
@@ -79,11 +80,19 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
this.watchWindowVisibility = this.watchWindowVisibility.bind(this);
|
||||
this.watchWorkspaceOpen = this.watchWorkspaceOpen.bind(this);
|
||||
this.watchOnline = this.watchOnline.bind(this);
|
||||
// Already bound
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.plugin.registerEvent(this.app.workspace.on("file-open", this.watchWorkspaceOpen));
|
||||
this.plugin.registerDomEvent(document, "visibilitychange", this.watchWindowVisibility);
|
||||
// Already bound
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.plugin.registerDomEvent(activeDocument, "visibilitychange", this.watchWindowVisibility);
|
||||
this.plugin.registerDomEvent(window, "focus", () => this.setHasFocus(true));
|
||||
this.plugin.registerDomEvent(window, "blur", () => this.setHasFocus(false));
|
||||
// Already bound
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.plugin.registerDomEvent(window, "online", this.watchOnline);
|
||||
// Already bound
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.plugin.registerDomEvent(window, "offline", this.watchOnline);
|
||||
}
|
||||
|
||||
@@ -121,7 +130,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const isHidden = document.hidden;
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
if (this.isLastHidden === isHidden) {
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +143,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
} else {
|
||||
// suspend all temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
if (!this.hasFocus) return;
|
||||
// Do not block resume by focus state here; visibility recovery should be enough.
|
||||
await this.services.appLifecycle.onResuming();
|
||||
await this.services.appLifecycle.onResumed();
|
||||
}
|
||||
@@ -222,9 +231,9 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
);
|
||||
});
|
||||
this.plugin.registerInterval(
|
||||
setInterval(() => {
|
||||
compatGlobal.setInterval(() => {
|
||||
__tick.value++;
|
||||
}, 1000) as unknown as number
|
||||
}, 1000)
|
||||
);
|
||||
|
||||
let stableCheck = 3;
|
||||
|
||||
@@ -111,7 +111,7 @@ export class ModuleDev extends AbstractObsidianModule {
|
||||
const filename = "test-create-conflict.md";
|
||||
const content = `# Test create conflict\n\n`;
|
||||
const w = await this.core.databaseFileAccess.store({
|
||||
name: filename as FilePathWithPrefix,
|
||||
name: filename,
|
||||
path: filename as FilePathWithPrefix,
|
||||
body: new Blob([content], { type: "text/markdown" }),
|
||||
stat: {
|
||||
|
||||
@@ -1,446 +0,0 @@
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import { LOG_LEVEL_NOTICE, REMOTE_MINIO, type FilePathWithPrefix } from "src/lib/src/common/types";
|
||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule";
|
||||
|
||||
export class ModuleIntegratedTest extends AbstractObsidianModule {
|
||||
async waitFor(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
|
||||
await delay(100);
|
||||
const start = Date.now();
|
||||
while (!(await proc())) {
|
||||
if (timeout > 0) {
|
||||
if (Date.now() - start > timeout) {
|
||||
this._log(`Timeout`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
waitWithReplicating(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
|
||||
return this.waitFor(async () => {
|
||||
await this.tryReplicate();
|
||||
return await proc();
|
||||
}, timeout);
|
||||
}
|
||||
async storageContentIsEqual(file: string, content: string): Promise<boolean> {
|
||||
try {
|
||||
const fileContent = await this.readStorageContent(file as FilePathWithPrefix);
|
||||
if (fileContent === content) {
|
||||
return true;
|
||||
} else {
|
||||
// this._log(`Content is not same \n Expected:${content}\n Actual:${fileContent}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
this._log(`Error: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async assert(proc: () => Promise<boolean>): Promise<boolean> {
|
||||
if (!(await proc())) {
|
||||
this._log(`Assertion failed`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async __orDie(key: string, proc: () => Promise<boolean>): Promise<true> | never {
|
||||
if (!(await this._test(key, proc))) {
|
||||
throw new Error(`${key}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
tryReplicate() {
|
||||
if (!this.settings.liveSync) {
|
||||
return shareRunningResult("replicate-test", async () => {
|
||||
await this.services.replication.replicate();
|
||||
});
|
||||
}
|
||||
}
|
||||
async readStorageContent(file: FilePathWithPrefix): Promise<string | undefined> {
|
||||
if (!(await this.core.storageAccess.isExistsIncludeHidden(file))) {
|
||||
return undefined;
|
||||
}
|
||||
return await this.core.storageAccess.readHiddenFileText(file);
|
||||
}
|
||||
async __proceed(no: number, title: string): Promise<boolean> {
|
||||
const stepFile = "_STEP.md" as FilePathWithPrefix;
|
||||
const stepAckFile = "_STEP_ACK.md" as FilePathWithPrefix;
|
||||
const stepContent = `Step ${no}`;
|
||||
await this.services.conflict.resolveByNewest(stepFile);
|
||||
await this.core.storageAccess.writeFileAuto(stepFile, stepContent);
|
||||
await this.__orDie(`Wait for acknowledge ${no}`, async () => {
|
||||
if (
|
||||
!(await this.waitWithReplicating(async () => {
|
||||
return await this.storageContentIsEqual(stepAckFile, stepContent);
|
||||
}, 20000))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
async __join(no: number, title: string): Promise<boolean> {
|
||||
const stepFile = "_STEP.md" as FilePathWithPrefix;
|
||||
const stepAckFile = "_STEP_ACK.md" as FilePathWithPrefix;
|
||||
// const otherStepFile = `_STEP_${isLeader ? "R" : "L"}.md` as FilePathWithPrefix;
|
||||
const stepContent = `Step ${no}`;
|
||||
|
||||
await this.__orDie(`Wait for step ${no} (${title})`, async () => {
|
||||
if (
|
||||
!(await this.waitWithReplicating(async () => {
|
||||
return await this.storageContentIsEqual(stepFile, stepContent);
|
||||
}, 20000))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
await this.services.conflict.resolveByNewest(stepAckFile);
|
||||
await this.core.storageAccess.writeFileAuto(stepAckFile, stepContent);
|
||||
await this.tryReplicate();
|
||||
return true;
|
||||
}
|
||||
|
||||
async performStep({
|
||||
step,
|
||||
title,
|
||||
isGameChanger,
|
||||
proc,
|
||||
check,
|
||||
}: {
|
||||
step: number;
|
||||
title: string;
|
||||
isGameChanger: boolean;
|
||||
proc: () => Promise<any>;
|
||||
check: () => Promise<boolean>;
|
||||
}): Promise<boolean> {
|
||||
if (isGameChanger) {
|
||||
await this.__proceed(step, title);
|
||||
try {
|
||||
await proc();
|
||||
} catch (e) {
|
||||
this._log(`Error: ${e}`);
|
||||
return false;
|
||||
}
|
||||
return await this.__orDie(`Step ${step} - ${title}`, async () => await this.waitWithReplicating(check));
|
||||
} else {
|
||||
return await this.__join(step, title);
|
||||
}
|
||||
}
|
||||
// // see scenario.md
|
||||
// async testLeader(testMain: (testFileName: FilePathWithPrefix) => Promise<boolean>): Promise<boolean> {
|
||||
|
||||
// }
|
||||
// async testReceiver(testMain: (testFileName: FilePathWithPrefix) => Promise<boolean>): Promise<boolean> {
|
||||
|
||||
// }
|
||||
async nonLiveTestRunner(
|
||||
isLeader: boolean,
|
||||
testMain: (testFileName: FilePathWithPrefix, isLeader: boolean) => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const storage = this.core.storageAccess;
|
||||
// const database = this.core.databaseFileAccess;
|
||||
// const _orDie = this._orDie.bind(this);
|
||||
const testCommandFile = "IT.md" as FilePathWithPrefix;
|
||||
const textCommandResponseFile = "ITx.md" as FilePathWithPrefix;
|
||||
let testFileName: FilePathWithPrefix;
|
||||
this.addTestResult(
|
||||
"-------Starting ... ",
|
||||
true,
|
||||
`Test as ${isLeader ? "Leader" : "Receiver"} command file ${testCommandFile}`
|
||||
);
|
||||
if (isLeader) {
|
||||
await this.__proceed(0, "start");
|
||||
}
|
||||
await this.tryReplicate();
|
||||
|
||||
await this.performStep({
|
||||
step: 0,
|
||||
title: "Make sure that command File Not Exists",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.removeHidden(testCommandFile),
|
||||
check: async () => !(await storage.isExistsIncludeHidden(testCommandFile)),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 1,
|
||||
title: "Make sure that command File Not Exists On Receiver",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => await storage.removeHidden(textCommandResponseFile),
|
||||
check: async () => !(await storage.isExistsIncludeHidden(textCommandResponseFile)),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 2,
|
||||
title: "Decide the test file name",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {
|
||||
testFileName = (Date.now() + "-" + Math.ceil(Math.random() * 1000) + ".md") as FilePathWithPrefix;
|
||||
const testCommandFile = "IT.md" as FilePathWithPrefix;
|
||||
await storage.writeFileAuto(testCommandFile, testFileName);
|
||||
},
|
||||
check: () => Promise.resolve(true),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 3,
|
||||
title: "Wait for the command file to be arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await storage.isExistsIncludeHidden(testCommandFile),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 4,
|
||||
title: "Send the response file",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {
|
||||
await storage.writeHiddenFileAuto(textCommandResponseFile, "!");
|
||||
},
|
||||
check: () => Promise.resolve(true),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 5,
|
||||
title: "Wait for the response file to be arrived",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await storage.isExistsIncludeHidden(textCommandResponseFile),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 6,
|
||||
title: "Proceed to begin the test",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {},
|
||||
check: () => Promise.resolve(true),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 6,
|
||||
title: "Begin the test",
|
||||
isGameChanger: !false,
|
||||
proc: async () => {},
|
||||
check: () => {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
});
|
||||
// await this.step(0, isLeader, true);
|
||||
try {
|
||||
this.addTestResult("** Main------", true, ``);
|
||||
if (isLeader) {
|
||||
return await testMain(testFileName!, true);
|
||||
} else {
|
||||
const testFileName = await this.readStorageContent(testCommandFile);
|
||||
this.addTestResult("testFileName", true, `Request client to use :${testFileName!}`);
|
||||
return await testMain(testFileName! as FilePathWithPrefix, false);
|
||||
}
|
||||
} finally {
|
||||
this.addTestResult("Teardown", true, `Deleting ${testFileName!}`);
|
||||
await storage.removeHidden(testFileName!);
|
||||
}
|
||||
|
||||
return true;
|
||||
// Make sure the
|
||||
}
|
||||
|
||||
async testBasic(filename: FilePathWithPrefix, isLeader: boolean): Promise<boolean> {
|
||||
const storage = this.core.storageAccess;
|
||||
const database = this.core.databaseFileAccess;
|
||||
|
||||
await this.addTestResult(
|
||||
`---**Starting Basic Test**---`,
|
||||
true,
|
||||
`Test as ${isLeader ? "Leader" : "Receiver"} command file ${filename}`
|
||||
);
|
||||
// if (isLeader) {
|
||||
// await this._proceed(0);
|
||||
// }
|
||||
// await this.tryReplicate();
|
||||
|
||||
await this.performStep({
|
||||
step: 0,
|
||||
title: "Make sure that file is not exist",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => !(await storage.isExists(filename)),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 1,
|
||||
title: "Write a file",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, "Hello World"),
|
||||
check: async () => await storage.isExists(filename),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 2,
|
||||
title: "Make sure the file is arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await storage.isExists(filename),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 3,
|
||||
title: "Update to Hello World 2",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, "Hello World 2"),
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 2"),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 4,
|
||||
title: "Make sure the modified file is arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 2"),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 5,
|
||||
title: "Update to Hello World 3",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, "Hello World 3"),
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 3"),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 6,
|
||||
title: "Make sure the modified file is arrived",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, "Hello World 3"),
|
||||
});
|
||||
|
||||
const multiLineContent = `Line1:A
|
||||
Line2:B
|
||||
Line3:C
|
||||
Line4:D`;
|
||||
|
||||
await this.performStep({
|
||||
step: 7,
|
||||
title: "Update to Multiline",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => await storage.writeFileAuto(filename, multiLineContent),
|
||||
check: async () => await this.storageContentIsEqual(filename, multiLineContent),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 8,
|
||||
title: "Make sure the modified file is arrived",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, multiLineContent),
|
||||
});
|
||||
|
||||
// While LiveSync, possibly cannot cause the conflict.
|
||||
if (!this.settings.liveSync) {
|
||||
// Step 9 Make Conflict But Resolvable
|
||||
const multiLineContentL = `Line1:A
|
||||
Line2:B
|
||||
Line3:C!
|
||||
Line4:D`;
|
||||
const multiLineContentC = `Line1:A
|
||||
Line2:bbbbb
|
||||
Line3:C
|
||||
Line4:D`;
|
||||
|
||||
await this.performStep({
|
||||
step: 9,
|
||||
title: "Progress to be conflicted",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {},
|
||||
check: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await storage.writeFileAuto(filename, isLeader ? multiLineContentL : multiLineContentC);
|
||||
|
||||
await this.performStep({
|
||||
step: 10,
|
||||
title: "Update As Conflicted",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 10,
|
||||
title: "Make sure Automatically resolved",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => (await database.getConflictedRevs(filename)).length === 0,
|
||||
});
|
||||
await this.performStep({
|
||||
step: 11,
|
||||
title: "Make sure Automatically resolved",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => (await database.getConflictedRevs(filename)).length === 0,
|
||||
});
|
||||
|
||||
const sensiblyMergedContent = `Line1:A
|
||||
Line2:bbbbb
|
||||
Line3:C!
|
||||
Line4:D`;
|
||||
|
||||
await this.performStep({
|
||||
step: 12,
|
||||
title: "Make sure Sensibly Merged on Leader",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, sensiblyMergedContent),
|
||||
});
|
||||
await this.performStep({
|
||||
step: 13,
|
||||
title: "Make sure Sensibly Merged on Receiver",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => await this.storageContentIsEqual(filename, sensiblyMergedContent),
|
||||
});
|
||||
}
|
||||
await this.performStep({
|
||||
step: 14,
|
||||
title: "Delete File",
|
||||
isGameChanger: isLeader,
|
||||
proc: async () => {
|
||||
await storage.removeHidden(filename);
|
||||
},
|
||||
check: async () => !(await storage.isExists(filename)),
|
||||
});
|
||||
|
||||
await this.performStep({
|
||||
step: 15,
|
||||
title: "Make sure File is deleted",
|
||||
isGameChanger: !isLeader,
|
||||
proc: async () => {},
|
||||
check: async () => !(await storage.isExists(filename)),
|
||||
});
|
||||
this._log(`The Basic Test has been completed`, LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
|
||||
async testBasicEvent(isLeader: boolean) {
|
||||
this.settings.liveSync = false;
|
||||
await this.saveSettings();
|
||||
await this._test("basic", async () => await this.nonLiveTestRunner(isLeader, (t, l) => this.testBasic(t, l)));
|
||||
}
|
||||
async testBasicLive(isLeader: boolean) {
|
||||
this.settings.liveSync = true;
|
||||
await this.saveSettings();
|
||||
await this._test("basic", async () => await this.nonLiveTestRunner(isLeader, (t, l) => this.testBasic(t, l)));
|
||||
}
|
||||
|
||||
async _everyModuleTestMultiDevice(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
const isLeader = this.core.services.vault.vaultName().indexOf("recv") === -1;
|
||||
this.addTestResult("-------", true, `Test as ${isLeader ? "Leader" : "Receiver"}`);
|
||||
try {
|
||||
this._log(`Starting Test`);
|
||||
await this.testBasicEvent(isLeader);
|
||||
if (this.settings.remoteType == REMOTE_MINIO) await this.testBasicLive(isLeader);
|
||||
} catch (e) {
|
||||
this._log(e);
|
||||
this._log(`Error: ${e}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
override onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
services.test.testMultiDevice.addHandler(this._everyModuleTestMultiDevice.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
// I intend to discontinue maintenance of this class. It seems preferable to test it externally.
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { eventHub } from "../../common/events";
|
||||
import { getWebCrypto } from "../../lib/src/mods.ts";
|
||||
import { uint8ArrayToHexString } from "octagonal-wheels/binary/hex";
|
||||
import { parseYaml, requestUrl, stringifyYaml } from "@/deps.ts";
|
||||
import type { FilePath } from "../../lib/src/common/types.ts";
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { getFileRegExp } from "../../lib/src/common/utils.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
"debug-sync-status": string[];
|
||||
}
|
||||
}
|
||||
|
||||
export class ModuleReplicateTest extends AbstractObsidianModule {
|
||||
testRootPath = "_test/";
|
||||
testInfoPath = "_testinfo/";
|
||||
|
||||
get isLeader() {
|
||||
return (
|
||||
this.services.vault.getVaultName().indexOf("dev") >= 0 &&
|
||||
this.services.vault.vaultName().indexOf("recv") < 0
|
||||
);
|
||||
}
|
||||
|
||||
get nameByKind() {
|
||||
if (!this.isLeader) {
|
||||
return "RECV";
|
||||
} else if (this.isLeader) {
|
||||
return "LEADER";
|
||||
}
|
||||
}
|
||||
get pairName() {
|
||||
if (this.isLeader) {
|
||||
return "RECV";
|
||||
} else if (!this.isLeader) {
|
||||
return "LEADER";
|
||||
}
|
||||
}
|
||||
|
||||
watchIsSynchronised = false;
|
||||
|
||||
statusBarSyncStatus?: HTMLElement;
|
||||
async readFileContent(file: string) {
|
||||
try {
|
||||
return await this.core.storageAccess.readHiddenFileText(file);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async dumpList() {
|
||||
if (this.settings.syncInternalFiles) {
|
||||
this._log("Write file list (Include Hidden)");
|
||||
await this.__dumpFileListIncludeHidden("files.md");
|
||||
} else {
|
||||
this._log("Write file list");
|
||||
await this.__dumpFileList("files.md");
|
||||
}
|
||||
}
|
||||
async _everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
await this.dumpList();
|
||||
return true;
|
||||
}
|
||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
this.addCommand({
|
||||
id: "dump-file-structure-normal",
|
||||
name: `Dump Structure (Normal)`,
|
||||
callback: () => {
|
||||
void this.__dumpFileList("files.md").finally(() => {
|
||||
void this.refreshSyncStatus();
|
||||
});
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "dump-file-structure-ih",
|
||||
name: "Dump Structure (Include Hidden)",
|
||||
callback: () => {
|
||||
const d = "files.md";
|
||||
void this.__dumpFileListIncludeHidden(d);
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "dump-file-structure-auto",
|
||||
name: "Dump Structure",
|
||||
callback: () => {
|
||||
void this.dumpList();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "dump-file-test",
|
||||
name: `Perform Test (Dev) ${this.isLeader ? "(Leader)" : "(Recv)"}`,
|
||||
callback: () => {
|
||||
void this.performTestManually();
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "watch-sync-result",
|
||||
name: `Watch sync result is matched between devices`,
|
||||
callback: () => {
|
||||
this.watchIsSynchronised = !this.watchIsSynchronised;
|
||||
void this.refreshSyncStatus();
|
||||
},
|
||||
});
|
||||
this.app.vault.on("modify", async (file) => {
|
||||
if (file.path.startsWith(this.testInfoPath)) {
|
||||
await this.refreshSyncStatus();
|
||||
} else {
|
||||
scheduleTask("dumpStatus", 125, async () => {
|
||||
await this.dumpList();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
this.statusBarSyncStatus = this.plugin.addStatusBarItem();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async getSyncStatusAsText() {
|
||||
const fileMine = this.testInfoPath + this.nameByKind + "/" + "files.md";
|
||||
const filePair = this.testInfoPath + this.pairName + "/" + "files.md";
|
||||
const mine = parseYaml(await this.readFileContent(fileMine));
|
||||
const pair = parseYaml(await this.readFileContent(filePair));
|
||||
const result = [] as string[];
|
||||
if (mine.length != pair.length) {
|
||||
result.push(`File count is different: ${mine.length} vs ${pair.length}`);
|
||||
}
|
||||
const filesAll = new Set([...mine.map((e: any) => e.path), ...pair.map((e: any) => e.path)]);
|
||||
for (const file of filesAll) {
|
||||
const mineFile = mine.find((e: any) => e.path == file);
|
||||
const pairFile = pair.find((e: any) => e.path == file);
|
||||
if (!mineFile || !pairFile) {
|
||||
result.push(`File not found: ${file}`);
|
||||
} else {
|
||||
if (mineFile.size != pairFile.size) {
|
||||
result.push(`Size is different: ${file} ${mineFile.size} vs ${pairFile.size}`);
|
||||
}
|
||||
if (mineFile.hash != pairFile.hash) {
|
||||
result.push(`Hash is different: ${file} ${mineFile.hash} vs ${pairFile.hash}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
eventHub.emitEvent("debug-sync-status", result);
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
async refreshSyncStatus() {
|
||||
if (this.watchIsSynchronised) {
|
||||
// Normal Files
|
||||
const syncStatus = await this.getSyncStatusAsText();
|
||||
if (syncStatus) {
|
||||
this.statusBarSyncStatus!.setText(`Sync Status: Having Error`);
|
||||
this._log(`Sync Status: Having Error\n${syncStatus}`, LOG_LEVEL_INFO);
|
||||
} else {
|
||||
this.statusBarSyncStatus!.setText(`Sync Status: Synchronised`);
|
||||
}
|
||||
} else {
|
||||
this.statusBarSyncStatus!.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
async __dumpFileList(outFile?: string) {
|
||||
if (!this.core || !this.core.storageAccess) {
|
||||
this._log("No storage access", LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
const files = await this.core.storageAccess.getFiles();
|
||||
const out = [] as any[];
|
||||
const webcrypto = await getWebCrypto();
|
||||
for (const file of files) {
|
||||
if (!(await this.services.vault.isTargetFile(file.path))) {
|
||||
continue;
|
||||
}
|
||||
if (file.path.startsWith(this.testInfoPath)) continue;
|
||||
const stat = await this.core.storageAccess.stat(file.path);
|
||||
if (stat) {
|
||||
const hashSrc = await this.core.storageAccess.readHiddenFileBinary(file.path);
|
||||
const hash = await webcrypto.subtle.digest("SHA-1", hashSrc);
|
||||
const hashStr = uint8ArrayToHexString(new Uint8Array(hash));
|
||||
const item = {
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
hash: hashStr,
|
||||
};
|
||||
// const fileLine = `-${file.path}:${stat.size}:${stat.mtime}:${hashStr}`;
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
if (outFile) {
|
||||
outFile = this.testInfoPath + this.nameByKind + "/" + outFile;
|
||||
await this.core.storageAccess.ensureDir(outFile);
|
||||
await this.core.storageAccess.writeHiddenFileAuto(outFile, stringifyYaml(out));
|
||||
} else {
|
||||
// console.dir(out);
|
||||
}
|
||||
this._log(`Dumped ${out.length} files`, LOG_LEVEL_INFO);
|
||||
}
|
||||
|
||||
async __dumpFileListIncludeHidden(outFile?: string) {
|
||||
const ignorePatterns = getFileRegExp(this.core.settings, "syncInternalFilesIgnorePatterns");
|
||||
const targetPatterns = getFileRegExp(this.core.settings, "syncInternalFilesTargetPatterns");
|
||||
const out = [] as any[];
|
||||
const files = await this.core.storageAccess.getFilesIncludeHidden("", targetPatterns, ignorePatterns);
|
||||
// console.dir(files);
|
||||
const webcrypto = await getWebCrypto();
|
||||
for (const file of files) {
|
||||
// if (!await this.core.$$isTargetFile(file)) {
|
||||
// continue;
|
||||
// }
|
||||
if (file.startsWith(this.testInfoPath)) continue;
|
||||
const stat = await this.core.storageAccess.statHidden(file);
|
||||
if (stat) {
|
||||
const hashSrc = await this.core.storageAccess.readHiddenFileBinary(file);
|
||||
const hash = await webcrypto.subtle.digest("SHA-1", hashSrc);
|
||||
const hashStr = uint8ArrayToHexString(new Uint8Array(hash));
|
||||
const item = {
|
||||
path: file,
|
||||
name: file.split("/").pop(),
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
hash: hashStr,
|
||||
};
|
||||
// const fileLine = `-${file.path}:${stat.size}:${stat.mtime}:${hashStr}`;
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
if (outFile) {
|
||||
outFile = this.testInfoPath + this.nameByKind + "/" + outFile;
|
||||
await this.core.storageAccess.ensureDir(outFile);
|
||||
await this.core.storageAccess.writeHiddenFileAuto(outFile, stringifyYaml(out));
|
||||
} else {
|
||||
// console.dir(out);
|
||||
}
|
||||
this._log(`Dumped ${out.length} files`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
|
||||
async collectTestFiles() {
|
||||
const remoteTopDir = "https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/refs/heads/main/";
|
||||
const files = [
|
||||
"README.md",
|
||||
"docs/adding_translations.md",
|
||||
"docs/design_docs_of_journalsync.md",
|
||||
"docs/design_docs_of_keep_newborn_chunks.md",
|
||||
"docs/design_docs_of_prefixed_hidden_file_sync.md",
|
||||
"docs/design_docs_of_sharing_tweak_value.md",
|
||||
"docs/quick_setup_cn.md",
|
||||
"docs/quick_setup_ja.md",
|
||||
"docs/quick_setup.md",
|
||||
"docs/settings_ja.md",
|
||||
"docs/settings.md",
|
||||
"docs/setup_cloudant_ja.md",
|
||||
"docs/setup_cloudant.md",
|
||||
"docs/setup_flyio.md",
|
||||
"docs/setup_own_server_cn.md",
|
||||
"docs/setup_own_server_ja.md",
|
||||
"docs/setup_own_server.md",
|
||||
"docs/tech_info_ja.md",
|
||||
"docs/tech_info.md",
|
||||
"docs/terms.md",
|
||||
"docs/troubleshooting.md",
|
||||
"images/1.png",
|
||||
"images/2.png",
|
||||
"images/corrupted_data.png",
|
||||
"images/hatch.png",
|
||||
"images/lock_pattern1.png",
|
||||
"images/lock_pattern2.png",
|
||||
"images/quick_setup_1.png",
|
||||
"images/quick_setup_2.png",
|
||||
"images/quick_setup_3.png",
|
||||
"images/quick_setup_3b.png",
|
||||
"images/quick_setup_4.png",
|
||||
"images/quick_setup_5.png",
|
||||
"images/quick_setup_6.png",
|
||||
"images/quick_setup_7.png",
|
||||
"images/quick_setup_8.png",
|
||||
"images/quick_setup_9_1.png",
|
||||
"images/quick_setup_9_2.png",
|
||||
"images/quick_setup_10.png",
|
||||
"images/remote_db_setting.png",
|
||||
"images/write_logs_into_the_file.png",
|
||||
];
|
||||
for (const file of files) {
|
||||
const remote = remoteTopDir + file;
|
||||
const local = this.testRootPath + file;
|
||||
try {
|
||||
const f = (await requestUrl(remote)).arrayBuffer;
|
||||
await this.core.storageAccess.ensureDir(local);
|
||||
await this.core.storageAccess.writeHiddenFileAuto(local, f);
|
||||
} catch (ex) {
|
||||
this._log(`Could not fetch ${remote}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
await this.dumpList();
|
||||
}
|
||||
|
||||
async waitFor(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
|
||||
await delay(100);
|
||||
const start = Date.now();
|
||||
while (!(await proc())) {
|
||||
if (timeout > 0) {
|
||||
if (Date.now() - start > timeout) {
|
||||
this._log(`Timeout`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async testConflictedManually1() {
|
||||
await this.services.replication.replicate();
|
||||
|
||||
const commonFile = `Resolve!
|
||||
*****, the amazing chocolatier!!`;
|
||||
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", commonFile);
|
||||
}
|
||||
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 1?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileA = `Resolve to KEEP THIS
|
||||
Willy Wonka, Willy Wonka, the amazing chocolatier!!`;
|
||||
|
||||
const fileB = `Resolve to DISCARD THIS
|
||||
Charlie Bucket, Charlie Bucket, the amazing chocolatier!!`;
|
||||
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", fileA);
|
||||
} else {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", fileB);
|
||||
}
|
||||
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to check the result of Manually 1?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
|
||||
if (
|
||||
!(await this.waitFor(async () => {
|
||||
await this.services.replication.replicate();
|
||||
return (
|
||||
(await this.__assertStorageContent(
|
||||
(this.testRootPath + "wonka.md") as FilePath,
|
||||
fileA,
|
||||
false,
|
||||
true
|
||||
)) == true
|
||||
);
|
||||
}, 30000))
|
||||
) {
|
||||
return await this.__assertStorageContent((this.testRootPath + "wonka.md") as FilePath, fileA, false, true);
|
||||
}
|
||||
return true;
|
||||
// We have to check the result
|
||||
}
|
||||
|
||||
async testConflictedManually2() {
|
||||
await this.services.replication.replicate();
|
||||
|
||||
const commonFile = `Resolve To concatenate
|
||||
ABCDEFG`;
|
||||
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", commonFile);
|
||||
}
|
||||
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 2?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileA = `Resolve to Concatenate
|
||||
ABCDEFGHIJKLMNOPQRSTYZ`;
|
||||
|
||||
const fileB = `Resolve to Concatenate
|
||||
AJKLMNOPQRSTUVWXYZ`;
|
||||
|
||||
const concatenated = `Resolve to Concatenate
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ`;
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", fileA);
|
||||
} else {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", fileB);
|
||||
}
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to test conflict Manually 2?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
|
||||
if (
|
||||
!(await this.waitFor(async () => {
|
||||
await this.services.replication.replicate();
|
||||
return (
|
||||
(await this.__assertStorageContent(
|
||||
(this.testRootPath + "concat.md") as FilePath,
|
||||
concatenated,
|
||||
false,
|
||||
true
|
||||
)) == true
|
||||
);
|
||||
}, 30000))
|
||||
) {
|
||||
return await this.__assertStorageContent(
|
||||
(this.testRootPath + "concat.md") as FilePath,
|
||||
concatenated,
|
||||
false,
|
||||
true
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async testConflictAutomatic() {
|
||||
if (this.isLeader) {
|
||||
const baseDoc = `Tasks!
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
- [ ] Task 3
|
||||
- [ ] Task 4
|
||||
`;
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", baseDoc);
|
||||
}
|
||||
await delay(100);
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to test conflict?", {
|
||||
timeout: 30,
|
||||
defaultOption: "Yes",
|
||||
})) == "no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const mod1Doc = `Tasks!
|
||||
- [ ] Task 1
|
||||
- [v] Task 2
|
||||
- [ ] Task 3
|
||||
- [ ] Task 4
|
||||
`;
|
||||
|
||||
const mod2Doc = `Tasks!
|
||||
- [ ] Task 1
|
||||
- [ ] Task 2
|
||||
- [v] Task 3
|
||||
- [ ] Task 4
|
||||
`;
|
||||
if (this.isLeader) {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", mod1Doc);
|
||||
} else {
|
||||
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", mod2Doc);
|
||||
}
|
||||
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
await delay(1000);
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Ready to check result?", { timeout: 30, defaultOption: "Yes" })) ==
|
||||
"no"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.services.replication.replicate();
|
||||
await this.services.replication.replicate();
|
||||
const mergedDoc = `Tasks!
|
||||
- [ ] Task 1
|
||||
- [v] Task 2
|
||||
- [v] Task 3
|
||||
- [ ] Task 4
|
||||
`;
|
||||
return this.__assertStorageContent((this.testRootPath + "task.md") as FilePath, mergedDoc, false, true);
|
||||
}
|
||||
|
||||
// No longer tested
|
||||
async checkConflictResolution() {
|
||||
this._log("Before testing conflicted files, resolve all once", LOG_LEVEL_NOTICE);
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.services.replication.replicate();
|
||||
await delay(1000);
|
||||
if (!(await this.testConflictAutomatic())) {
|
||||
this._log("Conflict resolution (Auto) failed", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.testConflictedManually1())) {
|
||||
this._log("Conflict resolution (Manual1) failed", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.testConflictedManually2())) {
|
||||
this._log("Conflict resolution (Manual2) failed", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async __assertStorageContent(
|
||||
fileName: FilePath,
|
||||
content: string,
|
||||
inverted = false,
|
||||
showResult = false
|
||||
): Promise<boolean | string> {
|
||||
try {
|
||||
const fileContent = await this.core.storageAccess.readHiddenFileText(fileName);
|
||||
let result = fileContent === content;
|
||||
if (inverted) {
|
||||
result = !result;
|
||||
}
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
if (showResult) {
|
||||
this._log(`Content is not same \n Expected:${content}\n Actual:${fileContent}`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
return `Content is not same \n Expected:${content}\n Actual:${fileContent}`;
|
||||
}
|
||||
} catch (e) {
|
||||
this._log(`Cannot assert storage content: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async performTestManually() {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
await this.checkConflictResolution();
|
||||
// await this.collectTestFiles();
|
||||
}
|
||||
|
||||
// testResults = writable<[boolean, string, string][]>([]);
|
||||
// testResults: string[] = [];
|
||||
|
||||
// $$addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void {
|
||||
// const logLine = `${name}: ${key} ${summary ?? ""}`;
|
||||
// this.testResults.update((results) => {
|
||||
// results.push([result, logLine, message ?? ""]);
|
||||
// return results;
|
||||
// });
|
||||
// }
|
||||
private async _everyModuleTestMultiDevice(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
// this.core.$$addTestResult("DevModule", "Test", true);
|
||||
// return Promise.resolve(true);
|
||||
await this._test("Conflict resolution", async () => await this.checkConflictResolution());
|
||||
return this.testDone();
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
|
||||
services.test.testMultiDevice.addHandler(this._everyModuleTestMultiDevice.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { perf_trench } from "./tests.ts";
|
||||
import { MarkdownRenderer, Notice } from "../../../deps.ts";
|
||||
import type { ModuleDev } from "../ModuleDev.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events.ts";
|
||||
import { writable } from "svelte/store";
|
||||
export let plugin: ObsidianLiveSyncPlugin;
|
||||
export let moduleDev: ModuleDev;
|
||||
$: core = plugin.core;
|
||||
let performanceTestResult = "";
|
||||
let functionCheckResult = "";
|
||||
let testRunning = false;
|
||||
let prefTestResultEl: HTMLDivElement;
|
||||
let isReady = false;
|
||||
|
||||
@@ -3,6 +3,11 @@ import TestPaneComponent from "./TestPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import type { ModuleDev } from "../ModuleDev.ts";
|
||||
export const VIEW_TYPE_TEST = "ols-pane-test";
|
||||
declare global {
|
||||
interface LSEvents {
|
||||
"debug-sync-status": string[];
|
||||
}
|
||||
}
|
||||
//Log view
|
||||
export class TestPaneView extends ItemView {
|
||||
component?: TestPaneComponent;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.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 {
|
||||
type DocumentID,
|
||||
@@ -16,6 +16,7 @@ import { fireAndForget, getDocData, readContent } from "../../../lib/src/common/
|
||||
import { isPlainText, stripPrefix } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
function isImage(path: string) {
|
||||
const ext = path.split(".").splice(-1)[0].toLowerCase();
|
||||
@@ -56,6 +57,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
info!: HTMLDivElement;
|
||||
fileInfo!: HTMLDivElement;
|
||||
showDiff = false;
|
||||
diffOnly = false;
|
||||
id?: DocumentID;
|
||||
|
||||
file: FilePathWithPrefix;
|
||||
@@ -70,6 +72,15 @@ export class DocumentHistoryModal extends Modal {
|
||||
currentDiffIndex = -1;
|
||||
diffNavContainer!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
diffOnlyLabel!: HTMLLabelElement;
|
||||
|
||||
// Search state
|
||||
searchKeyword = "";
|
||||
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
|
||||
currentSearchIndex = -1;
|
||||
searchResultIndicator!: HTMLSpanElement;
|
||||
searchProgressIndicator!: HTMLSpanElement;
|
||||
searchTimeout: number | null = null;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
@@ -88,9 +99,12 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (!file && id) {
|
||||
this.file = this.services.path.id2path(id);
|
||||
}
|
||||
if (localStorage.getItem("ols-history-highlightdiff") == "1") {
|
||||
if (this.app.loadLocalStorage("ols-history-highlightdiff") == "1") {
|
||||
this.showDiff = true;
|
||||
}
|
||||
if (this.app.loadLocalStorage("ols-history-diffonly") == "1") {
|
||||
this.diffOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
async loadFile(initialRev?: string) {
|
||||
@@ -145,22 +159,97 @@ export class DocumentHistoryModal extends Modal {
|
||||
return v;
|
||||
}
|
||||
|
||||
prepareContentView(usePreformatted = true) {
|
||||
this.contentView.empty();
|
||||
this.contentView.toggleClass("op-pre", usePreformatted);
|
||||
}
|
||||
|
||||
appendTextDiff(diff: [number, string][]) {
|
||||
let hasOmitted = false;
|
||||
for (const [operation, text] of diff) {
|
||||
if (operation == DIFF_DELETE) {
|
||||
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-deleted" }), text);
|
||||
hasOmitted = false;
|
||||
} else if (operation == DIFF_EQUAL) {
|
||||
if (this.diffOnly) {
|
||||
if (!hasOmitted) {
|
||||
this.contentView.appendText("\n...\n");
|
||||
hasOmitted = true;
|
||||
}
|
||||
} else {
|
||||
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-normal" }), text);
|
||||
}
|
||||
} else if (operation == DIFF_INSERT) {
|
||||
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-added" }), text);
|
||||
hasOmitted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendSearchHighlightedText(container: HTMLElement, text: string) {
|
||||
if (!this.searchKeyword) {
|
||||
container.appendText(text);
|
||||
return;
|
||||
}
|
||||
const escapedKeyword = this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(escapedKeyword, "gi");
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(regex)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > lastIndex) {
|
||||
container.appendText(text.slice(lastIndex, index));
|
||||
}
|
||||
container.createEl("mark", { text: match[0] });
|
||||
lastIndex = index + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
container.appendText(text.slice(lastIndex));
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const db = this.core.localDatabase;
|
||||
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||
this.currentText = "";
|
||||
this.currentDeleted = false;
|
||||
this.prepareContentView();
|
||||
if (w === false) {
|
||||
this.currentDeleted = true;
|
||||
this.info.innerHTML = "";
|
||||
this.contentView.innerHTML = `Could not read this revision<br>(${rev})`;
|
||||
this.info.empty();
|
||||
this.contentView.appendText("Could not read this revision");
|
||||
this.contentView.createEl("br");
|
||||
this.contentView.appendText(`(${rev})`);
|
||||
} else {
|
||||
this.currentDoc = w;
|
||||
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`;
|
||||
let result = undefined;
|
||||
this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`);
|
||||
const w1data = readDocument(w);
|
||||
this.currentDeleted = !!w.deleted;
|
||||
// this.currentText = w1data;
|
||||
if (typeof w1data == "string") {
|
||||
this.currentText = w1data;
|
||||
}
|
||||
let rendered = false;
|
||||
if (this.showDiff) {
|
||||
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
|
||||
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
|
||||
@@ -168,63 +257,65 @@ export class DocumentHistoryModal extends Modal {
|
||||
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
|
||||
if (w2 != false) {
|
||||
if (typeof w1data == "string") {
|
||||
result = "";
|
||||
const dmp = new diff_match_patch();
|
||||
const w2data = readDocument(w2) as string;
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
for (const v of diff) {
|
||||
const x1 = v[0];
|
||||
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>";
|
||||
const w2data = readDocument(w2);
|
||||
if (typeof w2data == "string") {
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.appendTextDiff(diff);
|
||||
rendered = true;
|
||||
}
|
||||
result = result.replace(/\n/g, "<br>");
|
||||
} else if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
const overlay = this.generateBlobURL(
|
||||
"overlay",
|
||||
readDocument(w2) as Uint8Array<ArrayBuffer>
|
||||
);
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
<img class='img-overlay' src='${overlay}'>
|
||||
</div>
|
||||
</div>`;
|
||||
this.contentView.removeClass("op-pre");
|
||||
this.prepareContentView(false);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice(false);
|
||||
}
|
||||
this.appendImageDiff(src, overlay);
|
||||
rendered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == undefined) {
|
||||
if (!rendered) {
|
||||
if (typeof w1data != "string") {
|
||||
if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
</div>
|
||||
</div>`;
|
||||
this.contentView.removeClass("op-pre");
|
||||
this.prepareContentView(false);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice(false);
|
||||
}
|
||||
this.appendImageDiff(src);
|
||||
} else {
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.contentView.appendText("Binary file");
|
||||
}
|
||||
} else {
|
||||
result = escapeStringToHTML(w1data);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.appendSearchHighlightedText(this.contentView, w1data);
|
||||
}
|
||||
}
|
||||
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
|
||||
this.contentView.innerHTML =
|
||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||
}
|
||||
// Reset diff navigation after content changes
|
||||
this.resetDiffNavigation();
|
||||
if (this.showDiff) {
|
||||
this.navigateDiff("next");
|
||||
} else if (this.searchKeyword) {
|
||||
const firstMark = this.contentView.querySelector("mark");
|
||||
if (firstMark) {
|
||||
firstMark.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,15 +336,14 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (direction === "next") {
|
||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||
} else {
|
||||
this.currentDiffIndex =
|
||||
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||
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}`;
|
||||
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,9 +354,9 @@ export class DocumentHistoryModal extends Modal {
|
||||
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";
|
||||
this.diffNavIndicator.setText(diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014");
|
||||
} else {
|
||||
this.diffNavIndicator.textContent = "\u2014";
|
||||
this.diffNavIndicator.setText("\u2014");
|
||||
}
|
||||
}
|
||||
this.updateDiffNavVisibility();
|
||||
@@ -279,6 +369,117 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (this.diffNavContainer) {
|
||||
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
|
||||
}
|
||||
if (this.diffOnlyLabel) {
|
||||
this.diffOnlyLabel.style.display = this.showDiff ? "inline-block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search through the last 100 revisions for the given keyword.
|
||||
*/
|
||||
async performSearch(keyword: string) {
|
||||
this.searchKeyword = keyword;
|
||||
this.searchResults = [];
|
||||
this.currentSearchIndex = -1;
|
||||
|
||||
if (!keyword) {
|
||||
this.searchResultIndicator.setText("");
|
||||
this.searchProgressIndicator.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
const db = this.core.localDatabase;
|
||||
const limit = 100;
|
||||
const totalRevs = this.revs_info.length;
|
||||
const end = Math.min(totalRevs, limit);
|
||||
|
||||
this.searchProgressIndicator.setText("Searching...");
|
||||
|
||||
const dmp = new diff_match_patch();
|
||||
|
||||
// 0 is the newest, higher index is older.
|
||||
for (let i = 0; i < end; i++) {
|
||||
const revInfo = this.revs_info[i];
|
||||
const rev = revInfo.rev;
|
||||
|
||||
this.searchProgressIndicator.setText(`Searching ${i + 1}/${end}...`);
|
||||
|
||||
const doc = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||
if (doc === false) continue;
|
||||
|
||||
const content = readDocument(doc);
|
||||
if (typeof content !== "string") continue;
|
||||
|
||||
const keywordLower = keyword.toLocaleLowerCase();
|
||||
|
||||
// Search in content
|
||||
if (content.toLocaleLowerCase().includes(keywordLower)) {
|
||||
this.searchResults.push({ rev, index: i, matchType: "Content" });
|
||||
this.updateSearchUI();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Search in diff (from older version to this version)
|
||||
// Older version is at i + 1
|
||||
if (i < totalRevs - 1) {
|
||||
const olderRev = this.revs_info[i + 1].rev;
|
||||
const olderDoc = await db.getDBEntry(this.file, { rev: olderRev }, false, false, true);
|
||||
if (olderDoc !== false) {
|
||||
const olderContent = readDocument(olderDoc);
|
||||
if (typeof olderContent === "string") {
|
||||
const diffs = dmp.diff_main(olderContent, content);
|
||||
let foundInDiff = false;
|
||||
for (const d of diffs) {
|
||||
if (
|
||||
(d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) &&
|
||||
d[1].toLocaleLowerCase().includes(keywordLower)
|
||||
) {
|
||||
foundInDiff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundInDiff) {
|
||||
this.searchResults.push({ rev, index: i, matchType: "Diff" });
|
||||
this.updateSearchUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.searchProgressIndicator.setText("Done");
|
||||
this.updateSearchUI();
|
||||
}
|
||||
|
||||
updateSearchUI() {
|
||||
if (this.searchResults.length === 0) {
|
||||
this.searchResultIndicator.setText(this.searchKeyword ? "No matches found" : "");
|
||||
} else {
|
||||
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
|
||||
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
|
||||
}
|
||||
}
|
||||
|
||||
navigateSearch(direction: "prev" | "next") {
|
||||
if (this.searchResults.length === 0) return;
|
||||
|
||||
if (direction === "next") {
|
||||
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length;
|
||||
} else {
|
||||
this.currentSearchIndex =
|
||||
this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1;
|
||||
}
|
||||
|
||||
const match = this.searchResults[this.currentSearchIndex];
|
||||
this.range.value = `${this.revs_info.length - 1 - match.index}`;
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
this.updateSearchUI();
|
||||
|
||||
// If it's a diff match, make sure Highlight diff is on
|
||||
if (match.matchType === "Diff" && !this.showDiff) {
|
||||
// We could auto-enable it, but maybe just notify the user?
|
||||
// For now, let's just let the user toggle it if they want to see the diff.
|
||||
}
|
||||
}
|
||||
|
||||
override onOpen() {
|
||||
@@ -287,6 +488,42 @@ export class DocumentHistoryModal extends Modal {
|
||||
contentEl.empty();
|
||||
this.fileInfo = contentEl.createDiv("");
|
||||
this.fileInfo.addClass("op-info");
|
||||
|
||||
// Search Row
|
||||
const searchRow = contentEl.createDiv("");
|
||||
searchRow.addClass("op-info");
|
||||
searchRow.addClass("search-row");
|
||||
searchRow.addClass("history-search-row");
|
||||
|
||||
const searchInput = searchRow.createEl("input", {
|
||||
type: "text",
|
||||
placeholder: "Search in history (last 100)...",
|
||||
});
|
||||
searchInput.addClass("history-search-input");
|
||||
searchInput.addEventListener("input", () => {
|
||||
if (this.searchTimeout) {
|
||||
compatGlobal.clearTimeout(this.searchTimeout);
|
||||
}
|
||||
this.searchTimeout = compatGlobal.setTimeout(() => {
|
||||
void this.performSearch(searchInput.value);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
|
||||
e.title = "Previous match";
|
||||
e.addEventListener("click", () => this.navigateSearch("prev"));
|
||||
});
|
||||
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
|
||||
e.title = "Next match";
|
||||
e.addEventListener("click", () => this.navigateSearch("next"));
|
||||
});
|
||||
|
||||
this.searchResultIndicator = searchRow.createEl("span", { text: "" });
|
||||
this.searchResultIndicator.addClass("history-search-result-indicator");
|
||||
|
||||
this.searchProgressIndicator = searchRow.createEl("span", { text: "" });
|
||||
this.searchProgressIndicator.addClass("history-search-progress-indicator");
|
||||
|
||||
const divView = contentEl.createDiv("");
|
||||
divView.addClass("op-flex");
|
||||
|
||||
@@ -302,24 +539,43 @@ export class DocumentHistoryModal extends Modal {
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
diffOptionsRow.addClass("op-info");
|
||||
diffOptionsRow.addClass("diff-options-row");
|
||||
diffOptionsRow.addClass("history-diff-options-row");
|
||||
|
||||
diffOptionsRow.createEl("label", {}, (label) => {
|
||||
label.appendChild(
|
||||
createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||
if (this.showDiff) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: any) => {
|
||||
this.showDiff = checkbox.checked;
|
||||
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
|
||||
this.updateDiffNavVisibility();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
})
|
||||
);
|
||||
const highlightDiffContainer = diffOptionsRow.createDiv("");
|
||||
highlightDiffContainer.addClass("history-highlight-diff-container");
|
||||
|
||||
highlightDiffContainer.createEl("label", {}, (label) => {
|
||||
label.addClass("history-highlight-diff-label");
|
||||
label.createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||
if (this.showDiff) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: any) => {
|
||||
this.showDiff = checkbox.checked;
|
||||
this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null);
|
||||
this.updateDiffNavVisibility();
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
});
|
||||
label.appendText("Highlight diff");
|
||||
});
|
||||
|
||||
const diffOnlyLabel = diffOptionsRow.createEl("label", {});
|
||||
diffOnlyLabel.createEl("input", { type: "checkbox" }, (checkbox) => {
|
||||
if (this.diffOnly) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
checkbox.addEventListener("input", (evt: any) => {
|
||||
this.diffOnly = checkbox.checked;
|
||||
this.app.saveLocalStorage("ols-history-diffonly", this.diffOnly == true ? "1" : null);
|
||||
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
|
||||
});
|
||||
});
|
||||
diffOnlyLabel.appendText("Diff only");
|
||||
diffOnlyLabel.addClass("diff-only-label");
|
||||
diffOnlyLabel.style.display = this.showDiff ? "inline-block" : "none";
|
||||
this.diffOnlyLabel = diffOnlyLabel;
|
||||
|
||||
// Diff navigation buttons
|
||||
this.diffNavContainer = diffOptionsRow.createDiv("");
|
||||
this.diffNavContainer.addClass("diff-nav");
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
</td>
|
||||
<td class="path">
|
||||
<div class="filenames">
|
||||
<span class="path">/{entry.dirname.split("/").join(`/`)}</span>
|
||||
<span class="path">/{entry.dirname.split("/").join(`\u200b/`)}</span>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { App, Modal } from "../../../deps.ts";
|
||||
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 { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import { delay } from "../../../lib/src/common/utils.ts";
|
||||
import { eventHub } from "../../../common/events.ts";
|
||||
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
|
||||
@@ -28,6 +27,9 @@ export class ConflictResolveModal extends Modal {
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
currentDiffIndex = -1;
|
||||
diffView!: HTMLDivElement;
|
||||
diffNavIndicator!: HTMLSpanElement;
|
||||
|
||||
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
|
||||
super(app);
|
||||
@@ -44,6 +46,52 @@ export class ConflictResolveModal extends Modal {
|
||||
// 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.setText(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");
|
||||
}
|
||||
|
||||
navigateDiff(direction: "prev" | "next") {
|
||||
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
|
||||
if (diffElements.length === 0) return;
|
||||
|
||||
const prevFocused = this.diffView.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.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
|
||||
}
|
||||
|
||||
resetDiffNavigation() {
|
||||
this.currentDiffIndex = -1;
|
||||
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
|
||||
this.diffNavIndicator.setText(diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014");
|
||||
}
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
@@ -60,29 +108,40 @@ export class ConflictResolveModal extends Modal {
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
contentEl.createEl("span", { text: this.filename });
|
||||
const div = contentEl.createDiv("");
|
||||
div.addClass("op-scrollable");
|
||||
div.addClass("ls-dialog");
|
||||
let diff = "";
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
diffOptionsRow.addClass("diff-options-row");
|
||||
diffOptionsRow.createEl("span", { text: this.filename });
|
||||
|
||||
const diffNavContainer = diffOptionsRow.createDiv("");
|
||||
diffNavContainer.addClass("diff-nav");
|
||||
diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => {
|
||||
e.addClass("diff-nav-btn");
|
||||
e.addEventListener("click", () => this.navigateDiff("prev"));
|
||||
});
|
||||
diffNavContainer.createEl("button", { text: "\u25BC Next" }, (e) => {
|
||||
e.addClass("diff-nav-btn");
|
||||
e.addEventListener("click", () => this.navigateDiff("next"));
|
||||
});
|
||||
this.diffNavIndicator = diffNavContainer.createEl("span", { text: "\u2014" });
|
||||
this.diffNavIndicator.addClass("diff-nav-indicator");
|
||||
|
||||
this.diffView = contentEl.createDiv("");
|
||||
this.diffView.addClass("op-scrollable");
|
||||
this.diffView.addClass("ls-dialog");
|
||||
let diffLength = 0;
|
||||
for (const v of this.result.diff) {
|
||||
const x1 = v[0];
|
||||
const x2 = v[1];
|
||||
diffLength += x2.length;
|
||||
if (diffLength > 100 * 1024) {
|
||||
continue;
|
||||
}
|
||||
if (x1 == DIFF_DELETE) {
|
||||
diff +=
|
||||
"<span class='deleted'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
this.appendDiffFragment(this.diffView, x2, "deleted");
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
diff +=
|
||||
"<span class='normal'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
this.appendDiffFragment(this.diffView, x2, "normal");
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
diff +=
|
||||
"<span class='added'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
this.appendDiffFragment(this.diffView, x2, "added");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,28 +151,32 @@ export class ConflictResolveModal extends Modal {
|
||||
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||
const date2 =
|
||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
div2.innerHTML = `<span class='deleted'><span class='conflict-dev-name'>${this.localName}</span>: ${date1}</span><br>
|
||||
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>`;
|
||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
|
||||
).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev))
|
||||
).style.marginRight = "4px";
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))
|
||||
).style.marginRight = "4px";
|
||||
contentEl.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED))
|
||||
).style.marginRight = "4px";
|
||||
diff = diff.replace(/\n/g, "<br>");
|
||||
if (diff.length > 100 * 1024) {
|
||||
div.innerText = "(Too large diff to display)";
|
||||
} else {
|
||||
div.innerHTML = diff;
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
});
|
||||
if (diffLength > 100 * 1024) {
|
||||
this.diffView.empty();
|
||||
this.diffView.setText("(Too large diff to display)");
|
||||
}
|
||||
this.resetDiffNavigation();
|
||||
this.navigateDiff("next");
|
||||
}
|
||||
|
||||
sendResponse(result: MergeDialogResult) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { logMessages } from "../../../lib/src/mock_and_interop/stores";
|
||||
import { reactive, type ReactiveInstance } from "octagonal-wheels/dataobject/reactive";
|
||||
import { Logger } from "../../../lib/src/common/logger";
|
||||
import { $msg as msg, currentLang as lang } from "../../../lib/src/common/i18n.ts";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { logMessages } from "../../../lib/src/mock_and_interop/stores";
|
||||
import { reactive, type ReactiveInstance } from "octagonal-wheels/dataobject/reactive";
|
||||
import { Logger } from "../../../lib/src/common/logger";
|
||||
import { $msg as msg, currentLang as lang } from "../../../lib/src/common/i18n.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
let unsubscribe: () => void;
|
||||
let unsubscribe: () => void;
|
||||
let messages = $state([] as string[]);
|
||||
let wrapRight = $state(false);
|
||||
let autoScroll = $state(true);
|
||||
@@ -16,90 +17,90 @@
|
||||
};
|
||||
let { close }: Props = $props();
|
||||
// export let close: () => void;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
const e = logs.value;
|
||||
if (!suspended) {
|
||||
messages = [...e];
|
||||
setTimeout(() => {
|
||||
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
const _logMessages = reactive(() => logMessages.value);
|
||||
_logMessages.onChanged(updateLog);
|
||||
Logger(msg("logPane.logWindowOpened", {}, lang));
|
||||
unsubscribe = () => _logMessages.offChanged(updateLog);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
let scroll: HTMLDivElement;
|
||||
function updateLog(logs: ReactiveInstance<string[]>) {
|
||||
const e = logs.value;
|
||||
if (!suspended) {
|
||||
messages = [...e];
|
||||
compatGlobal.setTimeout(() => {
|
||||
if (scroll) scroll.scrollTop = scroll.scrollHeight;
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
const _logMessages = reactive(() => logMessages.value);
|
||||
_logMessages.onChanged(updateLog);
|
||||
Logger(msg("logPane.logWindowOpened", {}, lang));
|
||||
unsubscribe = () => _logMessages.offChanged(updateLog);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
let scroll: HTMLDivElement;
|
||||
function closeDialogue() {
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="logpane">
|
||||
<!-- <h1>{msg("logPane.title", {}, lang)}</h1> -->
|
||||
<div class="control">
|
||||
<div class="row">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={wrapRight} />
|
||||
<span>{msg("logPane.wrap", {}, lang)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={autoScroll} />
|
||||
<span>{msg("logPane.autoScroll", {}, lang)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={suspended} />
|
||||
<span>{msg("logPane.pause", {}, lang)}</span>
|
||||
</label>
|
||||
<!-- <h1>{msg("logPane.title", {}, lang)}</h1> -->
|
||||
<div class="control">
|
||||
<div class="row">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={wrapRight} />
|
||||
<span>{msg("logPane.wrap", {}, lang)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={autoScroll} />
|
||||
<span>{msg("logPane.autoScroll", {}, lang)}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={suspended} />
|
||||
<span>{msg("logPane.pause", {}, lang)}</span>
|
||||
</label>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={() => closeDialogue()}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log" bind:this={scroll}>
|
||||
{#each messages as line}
|
||||
<pre class:wrap-right={wrapRight}>{line}</pre>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log" bind:this={scroll}>
|
||||
{#each messages as line}
|
||||
<pre class:wrap-right={wrapRight}>{line}</pre>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.logpane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log {
|
||||
overflow-y: scroll;
|
||||
user-select: text;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.logpane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log {
|
||||
overflow-y: scroll;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
padding-bottom: 2em;
|
||||
}
|
||||
.log > pre {
|
||||
margin: 0;
|
||||
}
|
||||
.log > pre.wrap-right {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.row > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 5em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
padding-bottom: 2em;
|
||||
}
|
||||
.log > pre {
|
||||
margin: 0;
|
||||
}
|
||||
.log > pre.wrap-right {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.row > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 5em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { WorkspaceLeaf } from "@/deps.ts";
|
||||
import LogPaneComponent from "./LogPane.svelte";
|
||||
import type ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { SvelteItemView } from "../../../common/SvelteItemView.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { mount } from "svelte";
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
//Log view
|
||||
|
||||
@@ -88,7 +88,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
|
||||
this._log(`Merge: Something went wrong: ${filename}, (${toDelete as string})`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
// In here, some merge has been processed.
|
||||
@@ -163,7 +163,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (e) {
|
||||
this._log(`Error while scanning conflicted files: ${e}`, LOG_LEVEL_NOTICE);
|
||||
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ import {
|
||||
EVENT_ON_UNRESOLVED_ERROR,
|
||||
} from "../../common/events.ts";
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import { addIcon, normalizePath, Notice } from "../../deps.ts";
|
||||
import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type WorkspaceLeaf } from "../../deps.ts";
|
||||
import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
} from "@lib/string_and_binary/path.ts";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts";
|
||||
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -50,18 +52,51 @@ const globalLogFunction = (message: any, level?: number, key?: string) => {
|
||||
const messageX =
|
||||
message instanceof Error
|
||||
? new LiveSyncError("[Error Logged]: " + message.message, { cause: message })
|
||||
: message;
|
||||
: typeof message === "string"
|
||||
? message
|
||||
: JSON.stringify(message);
|
||||
const entry = { message: messageX, level, key } as LogEntry;
|
||||
recentLogEntries.value = [...recentLogEntries.value, entry];
|
||||
};
|
||||
|
||||
setGlobalLogFunction(globalLogFunction);
|
||||
let recentLogs = [] as string[];
|
||||
// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs.
|
||||
// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI.
|
||||
const logForDump = [] as string[];
|
||||
|
||||
function addLog(log: string) {
|
||||
recentLogs = [...recentLogs, log].splice(-200);
|
||||
logMessages.value = recentLogs;
|
||||
logForDump.push(log);
|
||||
while (logForDump.length > 1000) {
|
||||
logForDump.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Display log is kept separate from the full log history to optimize performance and memory usage.
|
||||
// And debounce the updates to the display log to avoid excessive UI updates when there are many log entries in a short time.
|
||||
const logForDisplay = [] as string[];
|
||||
|
||||
const updateLogMessage = debounce(() => {
|
||||
logMessages.value = [...logForDisplay];
|
||||
}, 25);
|
||||
function addDisplayLog(log: string) {
|
||||
logForDisplay.push(log);
|
||||
while (logForDisplay.length > 200) {
|
||||
logForDisplay.shift();
|
||||
}
|
||||
updateLogMessage();
|
||||
}
|
||||
|
||||
const redactPatterns = [/PBKDF2 salt \(Security Seed\):.*$/];
|
||||
function redactLog(log: string) {
|
||||
let redactedLog = log;
|
||||
for (const pattern of redactPatterns) {
|
||||
redactedLog = redactedLog.replace(pattern, (match) => {
|
||||
return match.split(":")[0] + ": [REDACTED]";
|
||||
});
|
||||
}
|
||||
return redactedLog;
|
||||
}
|
||||
|
||||
// logStore.intercept(e => e.slice(Math.min(e.length - 200, 0)));
|
||||
|
||||
const showDebugLog = false;
|
||||
@@ -86,15 +121,15 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
// const emptyMark = `\u{2003}`;
|
||||
function padLeftSpComputed(numI: ReactiveValue<number>, mark: string) {
|
||||
const formatted = reactiveSource("");
|
||||
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
let timer: number | undefined = undefined;
|
||||
let maxLen = 1;
|
||||
numI.onChanged((numX) => {
|
||||
const num = numX.value;
|
||||
const numLen = `${Math.abs(num)}`.length + 1;
|
||||
maxLen = maxLen < numLen ? numLen : maxLen;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer) compatGlobal.clearTimeout(timer);
|
||||
if (num == 0) {
|
||||
timer = setTimeout(() => {
|
||||
timer = compatGlobal.setTimeout(() => {
|
||||
formatted.value = "";
|
||||
maxLen = 1;
|
||||
}, 3000);
|
||||
@@ -227,7 +262,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
this.statusDiv.remove();
|
||||
// this.statusDiv.pa();
|
||||
const container = mdv.view.containerEl;
|
||||
container.insertBefore(this.statusDiv, container.lastChild);
|
||||
container.appendChild(this.statusDiv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +358,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (this.nextFrameQueue) {
|
||||
return;
|
||||
}
|
||||
this.nextFrameQueue = requestAnimationFrame(() => {
|
||||
this.nextFrameQueue = compatGlobal.requestAnimationFrame(() => {
|
||||
this.nextFrameQueue = undefined;
|
||||
const { message, status } = this.statusBarLabels.value;
|
||||
// const recent = logMessages.value;
|
||||
@@ -346,7 +381,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
(a, b) => (a < b.ttl ? a : b.ttl),
|
||||
Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now);
|
||||
if (this.logLines.length > 0)
|
||||
compatGlobal.setTimeout(() => this.applyStatusBarText(), minimumNext - now);
|
||||
const recent = this.logLines.map((e) => e.message);
|
||||
const recentLogs = recent.reverse().join("\n");
|
||||
if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs;
|
||||
@@ -368,7 +404,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.remove();
|
||||
}
|
||||
document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove());
|
||||
compatGlobal.document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
@@ -390,7 +426,28 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
void this.services.API.showWindow(VIEW_TYPE_LOG);
|
||||
},
|
||||
});
|
||||
this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin));
|
||||
this.addCommand({
|
||||
id: "dump-debug-info",
|
||||
name: "Generate full report for opening the issue with debug info",
|
||||
callback: async () => {
|
||||
const recentLog = [...logForDump];
|
||||
const report = await generateReport(this.services.setting.currentSettings(), this.core);
|
||||
const info = {
|
||||
...report,
|
||||
recentLog: recentLog.map(redactLog),
|
||||
};
|
||||
const yaml = `\`\`\`\`
|
||||
# ---- Debug Info Dump ----
|
||||
${stringifyYaml(info)}
|
||||
\`\`\`\``;
|
||||
if (await this.services.UI.promptCopyToClipboard("Debug info", yaml)) {
|
||||
new Notice(
|
||||
"Debug info copied to clipboard. You can paste it in the issue. Be careful as it may contain sensitive information, review it before sharing."
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.registerView(VIEW_TYPE_LOG, (leaf: WorkspaceLeaf) => new LogPaneView(leaf, this.plugin));
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
||||
@@ -404,23 +461,27 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
void this.setFileStatus();
|
||||
});
|
||||
|
||||
const w = document.querySelectorAll(`.livesync-status`);
|
||||
const w = compatGlobal.document.querySelectorAll(`.livesync-status`);
|
||||
w.forEach((e) => e.remove());
|
||||
|
||||
this.observeForLogs();
|
||||
|
||||
this.statusDiv = this.app.workspace.containerEl.createDiv({ cls: "livesync-status" });
|
||||
this.statusLine = this.statusDiv.createDiv({ cls: "livesync-status-statusline" });
|
||||
this.messageArea = this.statusDiv.createDiv({ cls: "livesync-status-messagearea" });
|
||||
this.logMessage = this.statusDiv.createDiv({ cls: "livesync-status-logmessage" });
|
||||
this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" });
|
||||
this.statusDiv.style.display = this.settings?.showStatusOnEditor ? "" : "none";
|
||||
if (this.settings.showStatusOnEditor) {
|
||||
this.statusDiv = this.app.workspace.containerEl.createDiv({ cls: "livesync-status" });
|
||||
this.statusLine = this.statusDiv.createDiv({ cls: "livesync-status-statusline" });
|
||||
this.messageArea = this.statusDiv.createDiv({ cls: "livesync-status-messagearea" });
|
||||
this.logMessage = this.statusDiv.createDiv({ cls: "livesync-status-logmessage" });
|
||||
this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" });
|
||||
this.statusDiv.style.display = this.settings?.showStatusOnEditor ? "" : "none";
|
||||
}
|
||||
eventHub.onEvent(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition());
|
||||
if (this.settings?.showStatusOnStatusbar) {
|
||||
this.statusBar = this.services.API.addStatusBarItem();
|
||||
this.statusBar?.addClass("syncstatusbar");
|
||||
}
|
||||
this.adjustStatusDivPosition();
|
||||
this._log("Log module loaded", LOG_LEVEL_INFO);
|
||||
this._log("Verbose log", LOG_LEVEL_VERBOSE);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
@@ -444,11 +505,12 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (level == LOG_LEVEL_DEBUG && !showDebugLog) {
|
||||
return;
|
||||
}
|
||||
let memoOnly = false;
|
||||
if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) {
|
||||
return;
|
||||
memoOnly = true;
|
||||
}
|
||||
if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) {
|
||||
return;
|
||||
memoOnly = true;
|
||||
}
|
||||
const vaultName = this.services.vault.getVaultName();
|
||||
const now = new Date();
|
||||
@@ -456,7 +518,12 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
let errorInfo = "";
|
||||
if (message instanceof Error) {
|
||||
if (message instanceof LiveSyncError) {
|
||||
errorInfo = `${message.cause?.name}:${message.cause?.message}\n[StackTrace]: ${message.stack}\n[CausedBy]: ${message.cause?.stack}`;
|
||||
if (message.cause && message.cause instanceof Error) {
|
||||
const causedError = message.cause;
|
||||
errorInfo = `${causedError?.name}:${causedError?.message}\n[StackTrace]: ${message.stack}\n[CausedBy]: ${causedError?.stack}`;
|
||||
} else {
|
||||
errorInfo = `${message.name}:${message.message}\n[StackTrace]: ${message.stack}`;
|
||||
}
|
||||
} else {
|
||||
const thisStack = new Error().stack;
|
||||
errorInfo = `${message.name}:${message.message}\n[StackTrace]: ${message.stack}\n[LogCallStack]: ${thisStack}`;
|
||||
@@ -469,6 +536,15 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
? `${errorInfo}`
|
||||
: JSON.stringify(message, null, 2);
|
||||
const newMessage = timestamp + "->" + messageContent;
|
||||
|
||||
if (this.settings?.writeLogToTheFile) {
|
||||
this.writeLogToTheFile(now, vaultName, newMessage);
|
||||
}
|
||||
addLog(newMessage);
|
||||
if (memoOnly) {
|
||||
return;
|
||||
}
|
||||
addDisplayLog(newMessage);
|
||||
if (message instanceof Error) {
|
||||
console.error(vaultName + ":" + newMessage);
|
||||
} else if (level >= LOG_LEVEL_INFO) {
|
||||
@@ -479,10 +555,6 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
if (!this.settings?.showOnlyIconsOnEditor) {
|
||||
this.statusLog.value = messageContent;
|
||||
}
|
||||
if (this.settings?.writeLogToTheFile) {
|
||||
this.writeLogToTheFile(now, vaultName, newMessage);
|
||||
}
|
||||
addLog(newMessage);
|
||||
this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage });
|
||||
|
||||
if (level >= LOG_LEVEL_NOTICE) {
|
||||
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
type ValueComponent,
|
||||
} from "@/deps.ts";
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import {
|
||||
LEVEL_ADVANCED,
|
||||
LEVEL_POWER_USER,
|
||||
statusDisplay,
|
||||
type ConfigurationItem,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@lib/common/types.ts";
|
||||
import { createStub, type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import {
|
||||
type AllSettingItemKey,
|
||||
@@ -23,7 +18,7 @@ import {
|
||||
type AllNumericItemKey,
|
||||
type AllBooleanItemKey,
|
||||
} from "./settingConstants.ts";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { findAttrFromParent, wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
|
||||
|
||||
export class LiveSyncSetting extends Setting {
|
||||
|
||||
@@ -62,6 +62,7 @@ import { paneAdvanced } from "./PaneAdvanced.ts";
|
||||
import { panePowerUsers } from "./PanePowerUsers.ts";
|
||||
import { panePatches } from "./PanePatches.ts";
|
||||
import { paneMaintenance } from "./PaneMaintenance.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
// For creating a document
|
||||
const toc = new Set<string>();
|
||||
@@ -141,7 +142,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
|
||||
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
|
||||
if (key == "configPassphrase") {
|
||||
localStorage.setItem("ls-setting-passphrase", this.editingSettings?.[key] ?? "");
|
||||
compatGlobal.localStorage.setItem("ls-setting-passphrase", this.editingSettings?.[key] ?? "");
|
||||
return await Promise.resolve();
|
||||
}
|
||||
if (key == "deviceAndVaultName") {
|
||||
@@ -180,7 +181,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
// if (runOnSaved) {
|
||||
const handlers = this.onSavedHandlers
|
||||
.filter((e) => appliedKeys.indexOf(e.key) !== -1)
|
||||
.map((e) => e.handler(this.editingSettings[e.key as AllSettingItemKey]));
|
||||
.map((e) => Promise.resolve(e.handler(this.editingSettings[e.key as AllSettingItemKey])));
|
||||
await Promise.all(handlers);
|
||||
// }
|
||||
keys.forEach((e) => this.refreshSetting(e));
|
||||
@@ -214,7 +215,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
|
||||
reloadAllLocalSettings() {
|
||||
const ret = { ...OnDialogSettingsDefault };
|
||||
ret.configPassphrase = localStorage.getItem("ls-setting-passphrase") || "";
|
||||
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
|
||||
ret.preset = "";
|
||||
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
|
||||
return ret;
|
||||
@@ -349,7 +350,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
createEl<T extends keyof HTMLElementTagNameMap>(
|
||||
el: HTMLElement,
|
||||
tag: T,
|
||||
o?: string | DomElementInfo | undefined,
|
||||
o?: string | DomElementInfo,
|
||||
callback?: (el: HTMLElementTagNameMap[T]) => void,
|
||||
func?: OnUpdateFunc
|
||||
) {
|
||||
@@ -361,7 +362,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
addEl<T extends keyof HTMLElementTagNameMap>(
|
||||
el: HTMLElement,
|
||||
tag: T,
|
||||
o?: string | DomElementInfo | undefined,
|
||||
o?: string | DomElementInfo,
|
||||
callback?: (el: HTMLElementTagNameMap[T]) => void,
|
||||
func?: OnUpdateFunc
|
||||
) {
|
||||
@@ -647,7 +648,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.editingSettings.passphrase = "";
|
||||
}
|
||||
await this.saveAllDirtySettings();
|
||||
await this.applyAllSettings();
|
||||
await Promise.resolve(this.applyAllSettings());
|
||||
if (result == OPTION_FETCH) {
|
||||
await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG3_HR, "");
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
@@ -738,6 +739,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
);
|
||||
}
|
||||
setLevelClass(el, level);
|
||||
// TODO: Refactor to use Obsidian's recommended way to create heading.
|
||||
// eslint-disable-next-line obsidianmd/settings-tab/no-manual-html-headings
|
||||
el.createEl("h3", { text: title, cls: "sls-setting-pane-title" });
|
||||
if (this.menuEl) {
|
||||
this.menuEl.createEl(
|
||||
|
||||
@@ -35,6 +35,7 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme
|
||||
clampMin: 10,
|
||||
onUpdate: this.onlyOnCouchDB,
|
||||
});
|
||||
new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak");
|
||||
// new Setting(paneEl)
|
||||
// .setClass("wizardHidden")
|
||||
// .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB })
|
||||
|
||||
@@ -43,10 +43,13 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem
|
||||
// tmpDiv.addClass("sls-header-button");
|
||||
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)) {
|
||||
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
|
||||
informationButtonDiv.querySelector("button")?.addEventListener("click", () => {
|
||||
readEverythingButton.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
this.editingSettings.lastReadUpdates = lastVersion;
|
||||
await this.saveAllDirtySettings();
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { stringifyYaml } from "../../../deps.ts";
|
||||
import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
type FilePathWithPrefix,
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type LoadedEntry,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
type MetaEntry,
|
||||
type FilePath,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import {
|
||||
createBlob,
|
||||
getFileRegExp,
|
||||
isDocContentSame,
|
||||
parseHeaderValues,
|
||||
readAsBlob,
|
||||
} from "../../../lib/src/common/utils.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { isCloudantURI } from "../../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { requestToCouchDBWithCredentials } from "../../../common/utils.ts";
|
||||
import { addPrefix, shouldBeIgnored, stripAllPrefixes } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import { $msg } from "../../../lib/src/common/i18n.ts";
|
||||
} from "@lib/common/types.ts";
|
||||
import { createBlob, getFileRegExp, isDocContentSame, readAsBlob } from "@lib/common/utils.ts";
|
||||
import { Logger } from "@lib/common/logger.ts";
|
||||
import { addPrefix, shouldBeIgnored, stripAllPrefixes } from "@lib/string_and_binary/path.ts";
|
||||
import { $msg } from "@lib/common/i18n.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import {
|
||||
@@ -32,13 +19,13 @@ import {
|
||||
EVENT_REQUEST_RUN_DOCTOR,
|
||||
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
||||
eventHub,
|
||||
} from "../../../common/events.ts";
|
||||
import { ICHeader, ICXHeader, PSCHeader } from "../../../common/types.ts";
|
||||
import { HiddenFileSync } from "../../../features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts";
|
||||
import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts";
|
||||
} from "@/common/events.ts";
|
||||
import { ICHeader, ICXHeader, PSCHeader } from "@/common/types.ts";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { isNotFoundError } from "@lib/common/utils.doc.ts";
|
||||
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
|
||||
// hatchWarn.addClass("op-warn-info");
|
||||
@@ -69,140 +56,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
|
||||
button
|
||||
.setButtonText($msg("Copy Report to clipboard"))
|
||||
.setCta()
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
let responseConfig: any = {};
|
||||
const REDACTED = "𝑅𝐸𝐷𝐴𝐶𝑇𝐸𝐷";
|
||||
if (this.editingSettings.remoteType == REMOTE_COUCHDB) {
|
||||
try {
|
||||
const credential = generateCredentialObject(this.editingSettings);
|
||||
const customHeaders = parseHeaderValues(this.editingSettings.couchDB_CustomHeaders);
|
||||
const r = await requestToCouchDBWithCredentials(
|
||||
this.editingSettings.couchDB_URI,
|
||||
credential,
|
||||
window.origin,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
customHeaders
|
||||
);
|
||||
|
||||
Logger(JSON.stringify(r.json, null, 2));
|
||||
|
||||
responseConfig = r.json;
|
||||
responseConfig["couch_httpd_auth"].secret = REDACTED;
|
||||
responseConfig["couch_httpd_auth"].authentication_db = REDACTED;
|
||||
responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED;
|
||||
responseConfig["couchdb"].uuid = REDACTED;
|
||||
responseConfig["admins"] = REDACTED;
|
||||
delete responseConfig["jwt_keys"];
|
||||
if ("secret" in responseConfig["chttpd_auth"])
|
||||
responseConfig["chttpd_auth"].secret = REDACTED;
|
||||
} catch (ex) {
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
responseConfig = {
|
||||
error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.",
|
||||
};
|
||||
}
|
||||
} else if (this.editingSettings.remoteType == REMOTE_MINIO) {
|
||||
responseConfig = { error: "Object Storage Synchronisation" };
|
||||
//
|
||||
}
|
||||
const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[];
|
||||
const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings;
|
||||
const pluginKeys = Object.keys(pluginConfig);
|
||||
for (const key of pluginKeys) {
|
||||
if (defaultKeys.includes(key as any)) continue;
|
||||
delete pluginConfig[key as keyof ObsidianLiveSyncSettings];
|
||||
}
|
||||
|
||||
pluginConfig.couchDB_DBNAME = REDACTED;
|
||||
pluginConfig.couchDB_PASSWORD = REDACTED;
|
||||
const scheme = pluginConfig.couchDB_URI.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.couchDB_URI.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI)
|
||||
? "cloudant"
|
||||
: `self-hosted${scheme}`;
|
||||
pluginConfig.couchDB_USER = REDACTED;
|
||||
pluginConfig.passphrase = REDACTED;
|
||||
pluginConfig.encryptedPassphrase = REDACTED;
|
||||
pluginConfig.encryptedCouchDBConnection = REDACTED;
|
||||
pluginConfig.accessKey = REDACTED;
|
||||
pluginConfig.secretKey = REDACTED;
|
||||
const redact = (source: string) => `${REDACTED}(${source.length} letters)`;
|
||||
const toSchemeOnly = (uri: string) => {
|
||||
try {
|
||||
return `${new URL(uri).protocol}//`;
|
||||
} catch {
|
||||
const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//);
|
||||
return matched?.[0] ?? REDACTED;
|
||||
}
|
||||
};
|
||||
pluginConfig.remoteConfigurations = Object.fromEntries(
|
||||
Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [
|
||||
id,
|
||||
{
|
||||
...config,
|
||||
uri: toSchemeOnly(config.uri),
|
||||
},
|
||||
])
|
||||
);
|
||||
pluginConfig.region = redact(pluginConfig.region);
|
||||
pluginConfig.bucket = redact(pluginConfig.bucket);
|
||||
pluginConfig.pluginSyncExtendedSetting = {};
|
||||
pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID);
|
||||
pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase);
|
||||
pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID);
|
||||
pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays);
|
||||
pluginConfig.jwtKey = redact(pluginConfig.jwtKey);
|
||||
pluginConfig.jwtSub = redact(pluginConfig.jwtSub);
|
||||
pluginConfig.jwtKid = redact(pluginConfig.jwtKid);
|
||||
pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders);
|
||||
pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders);
|
||||
pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential);
|
||||
pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername);
|
||||
pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`;
|
||||
const endpoint = pluginConfig.endpoint;
|
||||
if (endpoint == "") {
|
||||
pluginConfig.endpoint = "Not configured or AWS";
|
||||
} else {
|
||||
const endpointScheme = pluginConfig.endpoint.startsWith("http:")
|
||||
? "(HTTP)"
|
||||
: pluginConfig.endpoint.startsWith("https:")
|
||||
? "(HTTPS)"
|
||||
: "";
|
||||
pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`;
|
||||
}
|
||||
const obsidianInfo = {
|
||||
navigator: navigator.userAgent,
|
||||
fileSystem: this.core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive",
|
||||
};
|
||||
const msgConfig = `# ---- Obsidian info ----
|
||||
${stringifyYaml(obsidianInfo)}
|
||||
---
|
||||
# ---- remote config ----
|
||||
${stringifyYaml(responseConfig)}
|
||||
---
|
||||
# ---- Plug-in config ----
|
||||
${stringifyYaml({
|
||||
version: this.manifestVersion,
|
||||
...pluginConfig,
|
||||
})}`;
|
||||
console.log(msgConfig);
|
||||
if ((await this.services.UI.promptCopyToClipboard("Generated report", msgConfig)) == true) {
|
||||
// await navigator.clipboard.writeText(msgConfig);
|
||||
// Logger(
|
||||
// `Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`,
|
||||
// LOG_LEVEL_NOTICE
|
||||
// );
|
||||
}
|
||||
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
|
||||
})
|
||||
);
|
||||
new Setting(paneEl)
|
||||
@@ -285,14 +146,14 @@ ${stringifyYaml({
|
||||
}
|
||||
if (!(await addOn.storeInternalFileToDatabase(file, true))) {
|
||||
Logger(
|
||||
`Failed to store the file to the database (Hidden file): ${file}`,
|
||||
`Failed to store the file to the database (Hidden file): ${file.path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!(await this.core.fileHandler.storeFileToDB(file as FilePath, true))) {
|
||||
if (!(await this.core.fileHandler.storeFileToDB(file, true))) {
|
||||
Logger(
|
||||
`Failed to store the file to the database: ${file}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
@@ -531,8 +392,8 @@ ${stringifyYaml({
|
||||
Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE);
|
||||
Logger(ret, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
} catch (ex: any) {
|
||||
if (ex?.status == 404) {
|
||||
} catch (ex: unknown) {
|
||||
if (isNotFoundError(ex)) {
|
||||
// We can perform this safely
|
||||
if ((await this.core.localDatabase.putRaw(newDoc)).ok) {
|
||||
Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -150,7 +150,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
xxhash64: "xxhash64 (Fastest)",
|
||||
"mixed-purejs": "PureJS fallback (Fast, W/O WebAssembly)",
|
||||
sha1: "Older fallback (Slow, W/O WebAssembly)",
|
||||
} as Record<HashAlgorithm, string>,
|
||||
} satisfies Record<HashAlgorithm, string>,
|
||||
});
|
||||
this.addOnSaved("hashAlg", async () => {
|
||||
await this.core.localDatabase._prepareHashFunctions();
|
||||
@@ -188,7 +188,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
}
|
||||
this.requestUpdate();
|
||||
};
|
||||
text.inputEl.before((dateEl = document.createElement("span")));
|
||||
text.inputEl.before((dateEl = activeDocument.createElement("span")));
|
||||
text.inputEl.type = "datetime-local";
|
||||
if (this.editingSettings.maxMTimeForReflectEvents > 0) {
|
||||
const date = new Date(this.editingSettings.maxMTimeForReflectEvents);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type ObsidianLiveSyncSettings,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { Menu } from "@/deps.ts";
|
||||
import { $msg } from "../../../lib/src/common/i18n.ts";
|
||||
@@ -288,7 +289,8 @@ export function paneRemoteConfig(
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(trimmedURI);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(`Failed to import remote configuration: ${ex}`, LOG_LEVEL_NOTICE);
|
||||
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
|
||||
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,9 +345,10 @@ export function paneRemoteConfig(
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(
|
||||
`Failed to parse remote configuration '${config.id}' for editing: ${ex}`,
|
||||
`Failed to parse remote configuration '${config.id}' for editing!`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
@@ -452,9 +455,10 @@ export function paneRemoteConfig(
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(
|
||||
`Failed to parse remote configuration '${config.id}': ${ex}`,
|
||||
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
|
||||
@@ -121,13 +121,13 @@ export function paneSetup(
|
||||
const repo = "vrtmrz/obsidian-livesync";
|
||||
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
|
||||
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
|
||||
this.createEl(
|
||||
paneEl,
|
||||
"div",
|
||||
"",
|
||||
(el) =>
|
||||
(el.innerHTML = `<a href='https://github.com/${repo}/blob/main${topPath}' target="_blank">${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}</a>`)
|
||||
);
|
||||
this.createEl(paneEl, "div", "", (el) => {
|
||||
el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
|
||||
anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener";
|
||||
});
|
||||
});
|
||||
const troubleShootEl = this.createEl(paneEl, "div", {
|
||||
text: "",
|
||||
cls: "sls-troubleshoot-preview",
|
||||
|
||||
@@ -75,7 +75,7 @@ export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncS
|
||||
if (config.isAdvanced && !showAdvanced) continue;
|
||||
const value =
|
||||
key != "E2EEAlgorithm"
|
||||
? `${setting[key]}`
|
||||
? `${setting[key] as string}`
|
||||
: E2EEAlgorithmNames[`${setting[key]}` as keyof typeof E2EEAlgorithmNames];
|
||||
const displayValue = config.isHidden ? "•".repeat(value.length) : escapeStringToHTML(value);
|
||||
outputSummary[config.name] = displayValue;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const checkConfig = async (
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
|
||||
let isSuccessful = true;
|
||||
const emptyDiv = createDiv();
|
||||
emptyDiv.innerHTML = "<span></span>";
|
||||
emptyDiv.createSpan();
|
||||
checkResultDiv?.replaceChildren(...[emptyDiv]);
|
||||
const addResult = (msg: string, classes?: string[]) => {
|
||||
const tmpDiv = createDiv();
|
||||
@@ -21,7 +21,7 @@ export const checkConfig = async (
|
||||
if (classes) {
|
||||
tmpDiv.addClasses(classes);
|
||||
}
|
||||
tmpDiv.innerHTML = `${msg}`;
|
||||
tmpDiv.textContent = msg;
|
||||
checkResultDiv?.appendChild(tmpDiv);
|
||||
};
|
||||
try {
|
||||
@@ -47,9 +47,10 @@ export const checkConfig = async (
|
||||
if (!checkResultDiv) return;
|
||||
const tmpDiv = createDiv();
|
||||
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);
|
||||
x.querySelector("button")?.addEventListener("click", () => {
|
||||
fixButton.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
|
||||
const res = await requestToCouchDBWithCredentials(
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type CouchDBConnection,
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
|
||||
} from "@lib/common/types.ts";
|
||||
import { isObjectDifferent } from "@lib/common/utils.ts";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
|
||||
@@ -21,8 +25,21 @@ import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte"
|
||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts";
|
||||
import { decodeSettingsFromQRCodeData } from "@lib/API/processSetting.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
|
||||
import type {
|
||||
OutroAskUserModeResultType,
|
||||
OutroExistingUserResultType,
|
||||
OutroNewUserResultType,
|
||||
ScanQRCodeResultType,
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteResultType,
|
||||
UseSetupURIResultType,
|
||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
@@ -117,7 +134,10 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
|
||||
*/
|
||||
async onUseSetupURI(userMode: UserMode, setupURI: string = ""): Promise<boolean> {
|
||||
const newSetting = await this.dialogManager.openWithExplicitCancel(UseSetupURI, setupURI);
|
||||
const newSetting = await this.dialogManager.openWithExplicitCancel<UseSetupURIResultType, string>(
|
||||
UseSetupURI,
|
||||
setupURI
|
||||
);
|
||||
if (newSetting === "cancelled") {
|
||||
this._log("Setup URI dialog cancelled.", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -140,7 +160,10 @@ export class SetupManager extends AbstractModule {
|
||||
): Promise<boolean> {
|
||||
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
|
||||
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, originalSetting);
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
CouchDBConnection
|
||||
>(SetupRemoteCouchDB, originalSetting);
|
||||
if (couchConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
@@ -164,7 +187,10 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const bucketConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteBucket, currentSetting);
|
||||
const bucketConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteBucketResultType,
|
||||
BucketSyncSetting
|
||||
>(SetupRemoteBucket, currentSetting);
|
||||
if (bucketConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
@@ -188,14 +214,33 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSetting);
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||
SetupRemoteP2P,
|
||||
currentSetting
|
||||
);
|
||||
if (p2pConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
|
||||
// Apply remoteConfigurations
|
||||
if (newSetting.P2P_ActiveRemoteConfigurationId) {
|
||||
const id = newSetting.P2P_ActiveRemoteConfigurationId;
|
||||
const merged = {
|
||||
...newSetting,
|
||||
...p2pConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
|
||||
newSetting.remoteConfigurations[id] = {
|
||||
...newSetting.remoteConfigurations[id],
|
||||
uri,
|
||||
isEncrypted: false,
|
||||
};
|
||||
newSetting.P2P_ActiveRemoteConfigurationId = id;
|
||||
}
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_P2P;
|
||||
newSetting.activeConfigurationId = newSetting.P2P_ActiveRemoteConfigurationId;
|
||||
}
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
@@ -207,10 +252,13 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, currentSetting);
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
|
||||
SetupRemoteE2EE,
|
||||
currentSetting
|
||||
);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await false;
|
||||
return false;
|
||||
}
|
||||
const newSetting = {
|
||||
...currentSetting,
|
||||
@@ -226,7 +274,10 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, originalSetting);
|
||||
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
|
||||
SetupRemoteE2EE,
|
||||
originalSetting
|
||||
);
|
||||
if (e2eeConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
@@ -245,7 +296,7 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const method = await this.dialogManager.openWithExplicitCancel(SetupRemote);
|
||||
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
|
||||
if (method === "couchdb") {
|
||||
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "bucket") {
|
||||
@@ -285,9 +336,9 @@ export class SetupManager extends AbstractModule {
|
||||
this._log("No changes in settings detected. Skipping applying settings from wizard.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
}
|
||||
const patch = generatePatchObj(this.settings, newConf);
|
||||
console.log(`Changes:`);
|
||||
console.dir(patch);
|
||||
// const patch = generatePatchObj(this.settings, newConf);
|
||||
// console.log(`Changes:`);
|
||||
// console.dir(patch);
|
||||
if (!activate) {
|
||||
extra();
|
||||
await this.applySetting(newConf, UserMode.ExistingUser);
|
||||
@@ -304,7 +355,8 @@ export class SetupManager extends AbstractModule {
|
||||
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
} else {
|
||||
const userModeResult = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
|
||||
const userModeResult =
|
||||
await this.dialogManager.openWithExplicitCancel<OutroAskUserModeResultType>(OutroAskUserMode);
|
||||
if (userModeResult === "new-user") {
|
||||
userMode = UserMode.NewUser;
|
||||
} else if (userModeResult === "existing-user") {
|
||||
@@ -321,7 +373,9 @@ export class SetupManager extends AbstractModule {
|
||||
}
|
||||
}
|
||||
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
|
||||
const confirm = await this.dialogManager.openWithExplicitCancel(component);
|
||||
const confirm = await this.dialogManager.openWithExplicitCancel<
|
||||
OutroNewUserResultType | OutroExistingUserResultType
|
||||
>(component);
|
||||
if (confirm === "cancelled") {
|
||||
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -347,10 +401,10 @@ export class SetupManager extends AbstractModule {
|
||||
*/
|
||||
|
||||
async onPromptQRCodeInstruction(): Promise<boolean> {
|
||||
const qrResult = await this.dialogManager.open(ScanQRCode);
|
||||
const qrResult = await this.dialogManager.open<ScanQRCodeResultType>(ScanQRCode);
|
||||
this._log("QR Code dialog closed.", LOG_LEVEL_VERBOSE);
|
||||
// Result is not used, but log it for debugging.
|
||||
this._log(`QR Code result: ${qrResult}`, LOG_LEVEL_VERBOSE);
|
||||
this._log(qrResult, LOG_LEVEL_VERBOSE);
|
||||
// QR Code instruction dialog never yields settings directly.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,30 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_IDENTICAL = "identical";
|
||||
const TYPE_INDEPENDENT = "independent";
|
||||
const TYPE_UNBALANCED = "unbalanced";
|
||||
const TYPE_CANCEL = "cancelled";
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import Check from "@lib/UI/components/Check.svelte";
|
||||
import {
|
||||
TYPE_BACKUP_DONE,
|
||||
TYPE_BACKUP_SKIPPED,
|
||||
TYPE_CANCEL,
|
||||
TYPE_IDENTICAL,
|
||||
TYPE_INDEPENDENT,
|
||||
TYPE_UNABLE_TO_BACKUP,
|
||||
TYPE_UNBALANCED,
|
||||
type FetchEverythingResult,
|
||||
type ResultTypeBackup,
|
||||
type ResultTypeVault,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
const TYPE_BACKUP_DONE = "backup_done";
|
||||
const TYPE_BACKUP_SKIPPED = "backup_skipped";
|
||||
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
|
||||
|
||||
type ResultTypeVault =
|
||||
| typeof TYPE_IDENTICAL
|
||||
| typeof TYPE_INDEPENDENT
|
||||
| typeof TYPE_UNBALANCED
|
||||
| typeof TYPE_CANCEL;
|
||||
type ResultTypeBackup =
|
||||
| typeof TYPE_BACKUP_DONE
|
||||
| typeof TYPE_BACKUP_SKIPPED
|
||||
| typeof TYPE_UNABLE_TO_BACKUP
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
type ResultTypeExtra = {
|
||||
preventFetchingConfig: boolean;
|
||||
};
|
||||
type ResultType =
|
||||
| {
|
||||
vault: ResultTypeVault;
|
||||
backup: ResultTypeBackup;
|
||||
extra: ResultTypeExtra;
|
||||
}
|
||||
| typeof TYPE_CANCEL;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: FetchEverythingResult) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let vaultType = $state<ResultTypeVault>(TYPE_CANCEL);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Option from "@/lib/src/UI/components/Option.svelte";
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_NEW_USER = "new-user";
|
||||
const TYPE_EXISTING_USER = "existing-user";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_NEW_USER | typeof TYPE_EXISTING_USER | typeof TYPE_CANCELLED;
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Option from "@lib/UI/components/Option.svelte";
|
||||
import Options from "@lib/UI/components/Options.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: IntroResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let userType = $state<IntroResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_NEW_USER) {
|
||||
return "Yes, I want to set up a new synchronisation";
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
const TYPE_EXISTING = "existing-user";
|
||||
const TYPE_NEW = "new-user";
|
||||
const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_EXISTING | typeof TYPE_NEW | typeof TYPE_COMPATIBLE_EXISTING | typeof TYPE_CANCELLED;
|
||||
import {
|
||||
type OutroAskUserModeResultType,
|
||||
TYPE_CANCELLED,
|
||||
TYPE_EXISTING,
|
||||
TYPE_NEW,
|
||||
TYPE_COMPATIBLE_EXISTING,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: OutroAskUserModeResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let userType = $state<OutroAskUserModeResultType>(TYPE_CANCELLED);
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_EXISTING || userType === TYPE_NEW || userType === TYPE_COMPATIBLE_EXISTING;
|
||||
});
|
||||
@@ -41,7 +44,11 @@
|
||||
</Guidance>
|
||||
<Instruction>
|
||||
<Question>Please select your situation.</Question>
|
||||
<Option title="I am setting up a new server for the first time / I want to reset my existing server." bind:value={userType} selectedValue={TYPE_NEW}>
|
||||
<Option
|
||||
title="I am setting up a new server for the first time / I want to reset my existing server."
|
||||
bind:value={userType}
|
||||
selectedValue={TYPE_NEW}
|
||||
>
|
||||
<InfoNote>
|
||||
Selecting this option will result in the current data on this device being used to initialise the server.
|
||||
Any existing data on the server will be completely overwritten.
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script lang="ts">
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_APPLY = "apply";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import Question from "@lib/UI/components/Question.svelte";
|
||||
import Instruction from "@lib/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
|
||||
import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: OutroExistingUserResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
import Question from "@/lib/src/UI/components/Question.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_APPLY = "apply";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
import { TYPE_APPLY, TYPE_CANCELLED, type OutroNewUserResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: OutroNewUserResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
// let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
// let userType = $state<OutroNewUserResultType>(TYPE_CANCELLED);
|
||||
</script>
|
||||
|
||||
<DialogHeader title="Setup Complete: Preparing to Initialise Server" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* Panel to check and fix CouchDB configuration issues
|
||||
*/
|
||||
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
|
||||
import Decision from "../../../../lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "../../../../lib/src/UI/components/UserDecisions.svelte";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
|
||||
type Props = {
|
||||
trialRemoteSetting: ObsidianLiveSyncSettings;
|
||||
|
||||
@@ -10,29 +10,17 @@
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_CANCEL = "cancelled";
|
||||
import {
|
||||
TYPE_CANCEL,
|
||||
TYPE_BACKUP_DONE,
|
||||
TYPE_BACKUP_SKIPPED,
|
||||
TYPE_UNABLE_TO_BACKUP,
|
||||
type RebuildEverythingResult,
|
||||
type ResultTypeBackup,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
const TYPE_BACKUP_DONE = "backup_done";
|
||||
const TYPE_BACKUP_SKIPPED = "backup_skipped";
|
||||
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
|
||||
|
||||
type ResultTypeBackup =
|
||||
| typeof TYPE_BACKUP_DONE
|
||||
| typeof TYPE_BACKUP_SKIPPED
|
||||
| typeof TYPE_UNABLE_TO_BACKUP
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
type ResultTypeExtra = {
|
||||
preventFetchingConfig: boolean;
|
||||
};
|
||||
type ResultType =
|
||||
| {
|
||||
backup: ResultTypeBackup;
|
||||
extra: ResultTypeExtra;
|
||||
}
|
||||
| typeof TYPE_CANCEL;
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: RebuildEverythingResult) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_CLOSE = "close";
|
||||
type ResultType = typeof TYPE_CLOSE;
|
||||
import { TYPE_CLOSE, type ScanQRCodeResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (_result: ScanQRCodeResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_USE_SETUP_URI = "use-setup-uri";
|
||||
const TYPE_SCAN_QR_CODE = "scan-qr-code";
|
||||
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_SCAN_QR_CODE | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
|
||||
import {
|
||||
TYPE_USE_SETUP_URI,
|
||||
TYPE_SCAN_QR_CODE,
|
||||
TYPE_CONFIGURE_MANUALLY,
|
||||
TYPE_CANCELLED,
|
||||
type SelectMethodExistingResultType,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: SelectMethodExistingResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let userType = $state<SelectMethodExistingResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
|
||||
import Check from "@/lib/src/UI/components/Check.svelte";
|
||||
const TYPE_USE_SETUP_URI = "use-setup-uri";
|
||||
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
|
||||
import {
|
||||
TYPE_USE_SETUP_URI,
|
||||
TYPE_CONFIGURE_MANUALLY,
|
||||
TYPE_CANCELLED,
|
||||
type SelectMethodNewUserResultType,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: SelectMethodNewUserResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let userType = $state<SelectMethodNewUserResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_USE_SETUP_URI) {
|
||||
return "Proceed with Setup URI";
|
||||
@@ -48,6 +48,8 @@
|
||||
bind:value={userType}
|
||||
>
|
||||
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>
|
||||
</Options>
|
||||
</Instruction>
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
import Options from "@/lib/src/UI/components/Options.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_COUCHDB = "couchdb";
|
||||
const TYPE_BUCKET = "bucket";
|
||||
const TYPE_P2P = "p2p";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
||||
import {
|
||||
TYPE_COUCHDB,
|
||||
TYPE_BUCKET,
|
||||
TYPE_P2P,
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteResultType,
|
||||
} from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: ResultType) => void;
|
||||
setResult: (result: SetupRemoteResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
let userType = $state<ResultType>(TYPE_CANCELLED);
|
||||
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_COUCHDB) {
|
||||
return "Continue to CouchDB setup";
|
||||
|
||||
@@ -13,19 +13,18 @@
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
RemoteTypes,
|
||||
} from "../../../../lib/src/common/types";
|
||||
} from "@lib/common/types";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { copyTo, pickBucketSyncSettings } from "../../../../lib/src/common/utils";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { copyTo, pickBucketSyncSettings } from "@lib/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
|
||||
|
||||
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<BucketSyncSetting>({ ...default_setting });
|
||||
|
||||
type ResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
type Props = GuestDialogProps<ResultType, BucketSyncSetting>;
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type Props = GuestDialogProps<SetupRemoteBucketResultType, BucketSyncSetting>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
|
||||
|
||||
@@ -14,20 +14,19 @@
|
||||
RemoteTypes,
|
||||
type CouchDBConnection,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../../../lib/src/common/types";
|
||||
import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
|
||||
} from "@lib/common/types";
|
||||
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { copyTo, pickCouchDBSyncSettings } from "../../../../lib/src/common/utils";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { copyTo, pickCouchDBSyncSettings } from "@lib/common/utils";
|
||||
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
|
||||
import { TYPE_CANCELLED, type SetupRemoteCouchDBResultType } from "./setupDialogTypes";
|
||||
|
||||
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
|
||||
type ResultType = typeof TYPE_CANCELLED | CouchDBConnection;
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type Props = GuestDialogProps<ResultType, CouchDBConnection>;
|
||||
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, CouchDBConnection>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
onMount(() => {
|
||||
if (getInitialData) {
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
E2EEAlgorithmNames,
|
||||
E2EEAlgorithms,
|
||||
type EncryptionSettings,
|
||||
} from "../../../../lib/src/common/types";
|
||||
} from "@lib/common/types";
|
||||
import { onMount } from "svelte";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "../../../../lib/src/common/utils";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_CANCELLED | EncryptionSettings;
|
||||
type Props = GuestDialogProps<ResultType, EncryptionSettings>;
|
||||
import type { GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { copyTo, pickEncryptionSettings } from "@lib/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let default_encryption: EncryptionSettings = {
|
||||
encrypt: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
// import { delay } from "octagonal-wheels/promises";
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@/lib/src/UI/components/InputRow.svelte";
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
import { PouchDB } from "../../../../lib/src/pouchdb/pouchdb-browser";
|
||||
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@lib/UI/components/Guidance.svelte";
|
||||
import Decision from "@lib/UI/components/Decision.svelte";
|
||||
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@lib/UI/components/InfoNote.svelte";
|
||||
import InputRow from "@lib/UI/components/InputRow.svelte";
|
||||
import Password from "@lib/UI/components/Password.svelte";
|
||||
import { PouchDB } from "@lib/pouchdb/pouchdb-browser";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
P2P_DEFAULT_SETTINGS,
|
||||
@@ -17,40 +17,40 @@
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PConnectionInfo,
|
||||
type P2PSyncSetting,
|
||||
} from "../../../../lib/src/common/types";
|
||||
} from "@lib/common/types";
|
||||
|
||||
import { TrysteroReplicator } from "../../../../lib/src/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "../../../../lib/src/replication/trystero/types";
|
||||
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "../../../../lib/src/common/utils";
|
||||
import { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator";
|
||||
import type { ReplicatorHostEnv } from "@lib/replication/trystero/types";
|
||||
import { copyTo, pickP2PSyncSettings, type SimpleStore } from "@lib/common/utils";
|
||||
import { onMount } from "svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../../lib/src/common/types";
|
||||
import ExtraItems from "../../../../lib/src/UI/components/ExtraItems.svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
|
||||
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
|
||||
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
|
||||
|
||||
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
|
||||
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
|
||||
|
||||
const context = getDialogContext();
|
||||
let error = $state("");
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type SettingInfo = P2PConnectionInfo;
|
||||
type ResultType = typeof TYPE_CANCELLED | SettingInfo;
|
||||
type Props = GuestDialogProps<ResultType, P2PSyncSetting>;
|
||||
type Props = GuestDialogProps<SetupRemoteP2PResultType, P2PSyncSetting>;
|
||||
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
onMount(() => {
|
||||
let initialData: P2PSyncSetting | undefined = undefined;
|
||||
if (getInitialData) {
|
||||
const initialData = getInitialData();
|
||||
initialData = getInitialData();
|
||||
if (initialData) {
|
||||
copyTo(initialData, syncSetting);
|
||||
}
|
||||
if (context.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME)) {
|
||||
syncSetting.P2P_DevicePeerName = context.services.config.getSmallConfig(
|
||||
SETTING_KEY_P2P_DEVICE_NAME
|
||||
) as string;
|
||||
} else {
|
||||
syncSetting.P2P_DevicePeerName = "";
|
||||
}
|
||||
}
|
||||
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() {
|
||||
@@ -100,7 +100,7 @@
|
||||
const dummyPouch = new PouchDB<EntryDoc>("dummy");
|
||||
const env: ReplicatorHostEnv = {
|
||||
settings: trialRemoteSetting,
|
||||
processReplicatedDocs: async (docs: any[]) => {
|
||||
processReplicatedDocs: async (_docs: any[]) => {
|
||||
return;
|
||||
},
|
||||
confirm: context.services.confirm,
|
||||
@@ -116,7 +116,7 @@
|
||||
await replicator.open();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// 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);
|
||||
if (replicator.knownAdvertisements.length > 0) {
|
||||
break;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { configURIBase } from "../../../../common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
|
||||
import { configURIBase } from "@/common/types";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
@@ -10,11 +10,11 @@
|
||||
import Password from "@/lib/src/UI/components/Password.svelte";
|
||||
|
||||
import { onMount } from "svelte";
|
||||
import { decryptString } from "../../../../lib/src/encryption/stringEncryption.ts";
|
||||
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog.ts";
|
||||
const TYPE_CANCELLED = "cancelled";
|
||||
type ResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
type Props = GuestDialogProps<ResultType, string>;
|
||||
import { decryptString } from "@lib/encryption/stringEncryption.ts";
|
||||
import type { GuestDialogProps } from "@lib/UI/svelteDialog.ts";
|
||||
import { TYPE_CANCELLED, type UseSetupURIResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = GuestDialogProps<UseSetupURIResultType, string>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
|
||||
let setupURI = $state("");
|
||||
|
||||
108
src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts
Normal file
108
src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
BucketSyncSetting,
|
||||
CouchDBConnection,
|
||||
EncryptionSettings,
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
} from "@lib/common/models/setting.type";
|
||||
|
||||
export const TYPE_IDENTICAL = "identical";
|
||||
export const TYPE_INDEPENDENT = "independent";
|
||||
export const TYPE_UNBALANCED = "unbalanced";
|
||||
export const TYPE_CANCEL = "cancelled";
|
||||
|
||||
export const TYPE_BACKUP_DONE = "backup_done";
|
||||
export const TYPE_BACKUP_SKIPPED = "backup_skipped";
|
||||
export const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
|
||||
|
||||
// Intro
|
||||
export const TYPE_NEW_USER = "new-user";
|
||||
export const TYPE_EXISTING_USER = "existing-user";
|
||||
export const TYPE_CANCELLED = "cancelled";
|
||||
|
||||
// Outro ask user mode
|
||||
export const TYPE_EXISTING = "existing-user";
|
||||
export const TYPE_NEW = "new-user";
|
||||
export const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
|
||||
|
||||
// OutroExistingUser
|
||||
export const TYPE_APPLY = "apply";
|
||||
|
||||
// Select methods
|
||||
export const TYPE_USE_SETUP_URI = "use-setup-uri";
|
||||
export const TYPE_SCAN_QR_CODE = "scan-qr-code";
|
||||
export const TYPE_CONFIGURE_MANUALLY = "configure-manually";
|
||||
|
||||
// ScanQRCode
|
||||
export const TYPE_CLOSE = "close";
|
||||
|
||||
// SetupRemote
|
||||
export const TYPE_COUCHDB = "couchdb";
|
||||
export const TYPE_BUCKET = "bucket";
|
||||
export const TYPE_P2P = "p2p";
|
||||
|
||||
export type ResultTypeVault =
|
||||
| typeof TYPE_IDENTICAL
|
||||
| typeof TYPE_INDEPENDENT
|
||||
| typeof TYPE_UNBALANCED
|
||||
| typeof TYPE_CANCEL;
|
||||
export type ResultTypeBackup =
|
||||
| typeof TYPE_BACKUP_DONE
|
||||
| typeof TYPE_BACKUP_SKIPPED
|
||||
| typeof TYPE_UNABLE_TO_BACKUP
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
export type ResultTypeExtra = {
|
||||
preventFetchingConfig: boolean;
|
||||
};
|
||||
export type FetchEverythingResult =
|
||||
| {
|
||||
vault: ResultTypeVault;
|
||||
backup: ResultTypeBackup;
|
||||
extra: ResultTypeExtra;
|
||||
}
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
export type RebuildEverythingResult =
|
||||
| {
|
||||
backup: ResultTypeBackup;
|
||||
extra: ResultTypeExtra;
|
||||
}
|
||||
| typeof TYPE_CANCEL;
|
||||
|
||||
export type IntroResultType = typeof TYPE_NEW_USER | typeof TYPE_EXISTING_USER | typeof TYPE_CANCELLED;
|
||||
|
||||
export type OutroAskUserModeResultType =
|
||||
| typeof TYPE_EXISTING
|
||||
| typeof TYPE_NEW
|
||||
| typeof TYPE_COMPATIBLE_EXISTING
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type OutroExistingUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
|
||||
export type OutroNewUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
|
||||
|
||||
export type SelectMethodNewUserResultType =
|
||||
| typeof TYPE_USE_SETUP_URI
|
||||
| typeof TYPE_CONFIGURE_MANUALLY
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type SelectMethodExistingResultType =
|
||||
| typeof TYPE_USE_SETUP_URI
|
||||
| typeof TYPE_SCAN_QR_CODE
|
||||
| typeof TYPE_CONFIGURE_MANUALLY
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
||||
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
|
||||
|
||||
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
|
||||
|
||||
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
|
||||
|
||||
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
|
||||
|
||||
export type ScanQRCodeResultType = typeof TYPE_CLOSE;
|
||||
@@ -13,6 +13,8 @@ import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { initialiseWorkerModule } from "@lib/worker/bgWorker.ts";
|
||||
import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars.ts";
|
||||
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
@@ -89,11 +91,6 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
// this.addUIs();
|
||||
//@ts-ignore
|
||||
const manifestVersion: string = MANIFEST_VERSION || "0.0.0";
|
||||
//@ts-ignore
|
||||
const packageVersion: string = PACKAGE_VERSION || "0.0.0";
|
||||
|
||||
this._log($msg("moduleLiveSyncMain.logPluginVersion", { manifestVersion, packageVersion }));
|
||||
await this.services.setting.loadSettings();
|
||||
if (!(await this.services.appLifecycle.onSettingLoaded())) {
|
||||
@@ -101,7 +98,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
const lsKey = "obsidian-live-sync-ver" + this.services.vault.getVaultName();
|
||||
const last_version = localStorage.getItem(lsKey);
|
||||
const last_version = compatGlobal.localStorage.getItem(lsKey);
|
||||
|
||||
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
|
||||
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
|
||||
@@ -123,7 +120,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate");
|
||||
await this.saveSettings();
|
||||
}
|
||||
localStorage.setItem(lsKey, `${VER}`);
|
||||
compatGlobal.localStorage.setItem(lsKey, `${VER}`);
|
||||
await this.services.database.openDatabase({
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
replicator: this.services.replicator,
|
||||
@@ -133,7 +130,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
// this.$$replicate = this.$$replicate.bind(this);
|
||||
// this.core.$$onLiveSyncReady = this.core.$$onLiveSyncReady.bind(this);
|
||||
await this.core.services.appLifecycle.onLoaded();
|
||||
await Promise.all(this.core.addOns.map((e) => e.onload()));
|
||||
await Promise.all(this.core.addOns.map((e) => Promise.resolve(e.onload())));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,17 @@ import { ObsHttpHandler } from "../essentialObsidian/APILib/ObsHttpHandler";
|
||||
import { ObsidianConfirm } from "./ObsidianConfirm";
|
||||
import type { Confirm } from "@lib/interfaces/Confirm";
|
||||
import { requestUrl, type RequestUrlParam } from "@/deps";
|
||||
import { compatGlobal } from "@/lib/src/common/coreEnvFunctions";
|
||||
// All Services will be migrated to be based on Plain Services, not Injectable Services.
|
||||
// This is a migration step.
|
||||
|
||||
declare module "obsidian" {
|
||||
interface App {
|
||||
appId?: string;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||
_customHandler: ObsHttpHandler | undefined;
|
||||
_confirmInstance: Confirm;
|
||||
@@ -38,6 +46,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() {
|
||||
return this.context.app;
|
||||
}
|
||||
@@ -64,8 +91,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
}
|
||||
}
|
||||
override isMobile(): boolean {
|
||||
//@ts-ignore : internal API
|
||||
return this.app.isMobile;
|
||||
return "isMobile" in this.app ? !!this.app.isMobile : false;
|
||||
}
|
||||
override getAppID(): string {
|
||||
return `${"appId" in this.app ? this.app.appId : ""}`;
|
||||
@@ -76,7 +102,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
}
|
||||
|
||||
override getAppVersion(): string {
|
||||
const navigatorString = globalThis.navigator?.userAgent ?? "";
|
||||
const navigatorString = compatGlobal.navigator?.userAgent ?? "";
|
||||
const match = navigatorString.match(/obsidian\/([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
if (match && match.length >= 2) {
|
||||
return match[1];
|
||||
@@ -177,7 +203,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
}
|
||||
|
||||
override setInterval(handler: () => void, timeout: number): number {
|
||||
const timerId = globalThis.setInterval(handler, timeout) as unknown as number;
|
||||
const timerId = compatGlobal.setInterval(handler, timeout);
|
||||
this.context.plugin.registerInterval(timerId);
|
||||
return timerId;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { compatGlobal } from "@/lib/src/common/coreEnvFunctions";
|
||||
import { type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
|
||||
import { eventHub } from "@lib/hub/hub";
|
||||
@@ -17,13 +18,16 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
|
||||
});
|
||||
}
|
||||
protected setItem(key: string, value: string) {
|
||||
return localStorage.setItem(key, value);
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.setItem(key, value);
|
||||
}
|
||||
protected getItem(key: string): string {
|
||||
return localStorage.getItem(key) ?? "";
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
return compatGlobal.localStorage.getItem(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
// TODO: Implement nativeLocalStorage.
|
||||
compatGlobal.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
|
||||
@@ -3,11 +3,22 @@ import { createServiceFeature } from "@lib/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
||||
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 } }) => {
|
||||
let isChanged = false;
|
||||
const settings = setting.currentSettings();
|
||||
if (settings.displayLanguage == "") {
|
||||
const obsidianLanguage = getLanguage();
|
||||
const obsidianLanguage = tryGetLanguage();
|
||||
if (
|
||||
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
|
||||
|
||||
@@ -5,9 +5,13 @@ import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/re
|
||||
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
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 { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
|
||||
import type {
|
||||
FetchEverythingResult,
|
||||
RebuildEverythingResult,
|
||||
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes";
|
||||
|
||||
/**
|
||||
* Flag file handler interface, similar to target filter pattern.
|
||||
@@ -65,7 +69,8 @@ export function createFetchAllFlagHandler(
|
||||
|
||||
// Handle the fetch all scheduled operation
|
||||
const onScheduled = async () => {
|
||||
const method = await host.services.UI.dialogManager.openWithExplicitCancel(FetchEverything);
|
||||
const method =
|
||||
await host.services.UI.dialogManager.openWithExplicitCancel<FetchEverythingResult>(FetchEverything);
|
||||
if (method === "cancelled") {
|
||||
log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await cleanupFlag();
|
||||
@@ -73,7 +78,7 @@ export function createFetchAllFlagHandler(
|
||||
return false;
|
||||
}
|
||||
const { vault, extra } = method;
|
||||
const settings = await host.services.setting.currentSettings();
|
||||
const settings = await Promise.resolve(host.services.setting.currentSettings());
|
||||
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
|
||||
const makeLocalChunkBeforeSyncAvailable = settings.remoteType !== REMOTE_MINIO;
|
||||
const mapVaultStateToAction = {
|
||||
@@ -200,6 +205,13 @@ export async function adjustSettingToRemoteIfNeeded(
|
||||
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.
|
||||
if (await adjustSettingToRemote(host, log, config)) {
|
||||
config = host.services.setting.currentSettings();
|
||||
@@ -289,7 +301,8 @@ export function createRebuildFlagHandler(
|
||||
|
||||
// Handle the rebuild everything scheduled operation
|
||||
const onScheduled = async () => {
|
||||
const method = await host.services.UI.dialogManager.openWithExplicitCancel(RebuildEverything);
|
||||
const method =
|
||||
await host.services.UI.dialogManager.openWithExplicitCancel<RebuildEverythingResult>(RebuildEverything);
|
||||
if (method === "cancelled") {
|
||||
log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await cleanupFlag();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user