mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-30 07:10:30 +03:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
02673a1631 | ||
|
|
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'
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,4 +28,6 @@ data.json
|
||||
cov_profile/**
|
||||
|
||||
coverage
|
||||
src/apps/cli/dist/*
|
||||
src/apps/cli/dist/*
|
||||
_testdata/**
|
||||
utils/bench/splitResults.csv
|
||||
@@ -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.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.64",
|
||||
"version": "0.25.70",
|
||||
"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",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
}
|
||||
|
||||
290
package-lock.json
generated
290
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.64",
|
||||
"version": "0.25.70",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.64",
|
||||
"version": "0.25.70",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -15,7 +15,7 @@
|
||||
"@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",
|
||||
"@trystero-p2p/nostr": "^0.24.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -28,7 +28,7 @@
|
||||
"octagonal-wheels": "^0.1.45",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1851,9 +1851,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1887,7 +1887,7 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers/node_modules/@eslint/core": {
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
@@ -1932,9 +1932,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1984,19 +1984,6 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/json/node_modules/@eslint/core": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
@@ -2021,19 +2008,6 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fidm/asn1": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
|
||||
@@ -2710,9 +2684,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/secp256k1": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz",
|
||||
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
|
||||
"integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -3572,20 +3546,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.23.12",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz",
|
||||
"integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==",
|
||||
"version": "3.24.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz",
|
||||
"integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/protocol-http": "^5.3.12",
|
||||
"@smithy/types": "^4.13.1",
|
||||
"@smithy/url-parser": "^4.2.12",
|
||||
"@smithy/util-base64": "^4.3.2",
|
||||
"@smithy/util-body-length-browser": "^4.2.2",
|
||||
"@smithy/util-middleware": "^4.2.12",
|
||||
"@smithy/util-stream": "^4.5.20",
|
||||
"@smithy/util-utf8": "^4.2.2",
|
||||
"@smithy/uuid": "^1.1.2",
|
||||
"@aws-crypto/crc32": "5.2.0",
|
||||
"@smithy/types": "^4.14.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3752,11 +3719,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/is-array-buffer": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz",
|
||||
"integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.3.3.tgz",
|
||||
"integrity": "sha512-RRxYqjUa/n8dRVkbhyuiRarppLzt4H/AtMUEFmiHlDy8o4wrgqAdzxsk9naemzu6iX67ZV375fNmX7Q8dynGKw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.24.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4020,9 +3988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
|
||||
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
|
||||
"version": "4.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz",
|
||||
"integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -4084,12 +4052,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-buffer-from": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz",
|
||||
"integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.3.3.tgz",
|
||||
"integrity": "sha512-5xlgilVaX96HdVlLZymKUa7vOTZtisOTxBJloM2J4PeRqyAWBeFIq0DnIxQISvwxT4rgJAvk7rHhB+GlCCKe8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/is-array-buffer": "^4.2.2",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4226,12 +4194,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-utf8": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz",
|
||||
"integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.3.tgz",
|
||||
"integrity": "sha512-c1QpRBn3aMsoqE64dd4Imgjy8Pynfw+eR7GkjElquxUFSnezwYVaOFm8JcYa+Bo/5ssbEyPKcT3+4bmrWYh6eQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^4.2.2",
|
||||
"@smithy/core": "^3.24.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4329,19 +4297,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@trystero-p2p/core": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.23.0.tgz",
|
||||
"integrity": "sha512-ozhtgxKDZH11Gdef0wH8xivwAE/L0/lDFvEcNFWPJWnHZlxWPPyfeonwE287ssGevQNi10vnj6x2ZcOi0n1bQQ==",
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/core/-/core-0.24.0.tgz",
|
||||
"integrity": "sha512-W5ATiflgzZLE21fN2VA3YsK2yBJEzCvhmJ/9q2Vm3QT/gcdqDpcBxsO0DYCy/wE1PBEwoB+A75eBNtGIGAPdxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@trystero-p2p/nostr": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.23.0.tgz",
|
||||
"integrity": "sha512-KSqUR2c1KVfv4zeErcntuegtyKzFTzNNiitIKGD0LiKA/4H3CeTF81ROk2h+X/PNvP4mv7Gp5eVxFYwfMu4Nrg==",
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@trystero-p2p/nostr/-/nostr-0.24.0.tgz",
|
||||
"integrity": "sha512-LmsJSicsFU/rhmOWYaP/OxFl3rwGieX+q0eh0pAWUQM7IXbMu6tLC5+aAimtHitikPv9r6sck6EUTWMin8dBAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@trystero-p2p/core": "0.23.0"
|
||||
"@noble/secp256k1": "^3.1.0",
|
||||
"@trystero-p2p/core": "0.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/svelte": {
|
||||
@@ -4521,9 +4489,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5255,9 +5223,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@wdio/config/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5377,9 +5345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@wdio/repl/node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5407,9 +5375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@wdio/types/node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5532,9 +5500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5623,9 +5591,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6234,9 +6202,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -7150,9 +7118,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
|
||||
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
@@ -7914,9 +7882,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8262,14 +8230,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/resolve": {
|
||||
"version": "2.0.0-next.6",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||
"integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==",
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
"integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"is-core-module": "^2.16.2",
|
||||
"node-exports-info": "^1.6.0",
|
||||
"object-keys": "^1.1.1",
|
||||
"path-parse": "^1.0.7",
|
||||
@@ -8432,19 +8400,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/@eslint/core": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -8453,9 +8408,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9289,9 +9244,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/globby/node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9443,9 +9398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9786,13 +9741,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.2"
|
||||
"hasown": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -10973,9 +10928,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz",
|
||||
"integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -12695,9 +12650,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/brace-expansion": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12817,12 +12772,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"is-core-module": "^2.16.1",
|
||||
"path-parse": "^1.0.7",
|
||||
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||
@@ -13119,9 +13075,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -15293,9 +15249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.24.7",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -16157,9 +16113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/es-module-lexer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -16240,9 +16196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver/node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -16250,9 +16206,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webdriver/node_modules/undici": {
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz",
|
||||
"integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -16312,9 +16268,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webdriverio/node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -16335,9 +16291,9 @@
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/werift": {
|
||||
"version": "0.22.9",
|
||||
"resolved": "https://registry.npmjs.org/werift/-/werift-0.22.9.tgz",
|
||||
"integrity": "sha512-TE9AxskSRWBMYm0MBRllfnKVXQelqC76JCvyolQyVWpmKabfY5BA/cuvkGg+71JWn3QrGih1YWtpIWGPqoxcoA==",
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz",
|
||||
"integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fidm/x509": "^1.2.1",
|
||||
@@ -16778,9 +16734,9 @@
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -16848,9 +16804,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.64",
|
||||
"version": "0.25.70",
|
||||
"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",
|
||||
@@ -130,7 +130,7 @@
|
||||
"@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",
|
||||
"@trystero-p2p/nostr": "^0.24.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"obsidian": "^1.12.3",
|
||||
@@ -143,7 +143,7 @@
|
||||
"octagonal-wheels": "^0.1.45",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -469,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 {
|
||||
@@ -67,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);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
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());
|
||||
@@ -48,6 +49,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -58,6 +71,7 @@
|
||||
});
|
||||
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||
roomSuffix = extractP2PRoomSuffix(settings?.P2P_roomID ?? "");
|
||||
useDiagRTC = settings?.P2P_useDiagRTC ?? false;
|
||||
});
|
||||
|
||||
fireAndForget(async () => {
|
||||
@@ -131,6 +145,48 @@
|
||||
</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>
|
||||
@@ -190,6 +246,11 @@
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.diag-toggle-row {
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.broadcast-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-normal);
|
||||
@@ -221,4 +282,29 @@
|
||||
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>
|
||||
@@ -16,10 +16,7 @@
|
||||
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 { 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";
|
||||
@@ -36,9 +33,7 @@
|
||||
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 ?? ""
|
||||
);
|
||||
let syncOnReplicationSetting = $state(core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? "");
|
||||
type P2PRemoteOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -51,12 +46,19 @@
|
||||
let selectingP2PRemote = $state(false);
|
||||
|
||||
function addToList(item: string, list: string): string {
|
||||
const items = list.split(",").map((e) => e.trim()).filter((e) => e);
|
||||
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(",");
|
||||
return list
|
||||
.split(",")
|
||||
.map((e) => e.trim())
|
||||
.filter((e) => e && e !== item)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function markCommunicating(peerId: string) {
|
||||
@@ -409,7 +411,12 @@
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="icon-button" onclick={() => createAndSelectP2PRemote()} title="Create P2P remote" aria-label="Create P2P remote">
|
||||
<button
|
||||
class="icon-button"
|
||||
onclick={() => createAndSelectP2PRemote()}
|
||||
title="Create P2P remote"
|
||||
aria-label="Create P2P remote"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
@@ -442,7 +449,8 @@
|
||||
<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>
|
||||
{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}
|
||||
@@ -460,11 +468,11 @@
|
||||
<button
|
||||
class="emoji-button"
|
||||
disabled={replicatingPeerId !== null}
|
||||
title={replicatingPeerId === peer.peerId ? 'Replicating...' : 'Replicate now'}
|
||||
aria-label={replicatingPeerId === peer.peerId ? 'Replicating' : 'Replicate now'}
|
||||
title={replicatingPeerId === peer.peerId ? "Replicating..." : "Replicate now"}
|
||||
aria-label={replicatingPeerId === peer.peerId ? "Replicating" : "Replicate now"}
|
||||
onclick={() => startReplication(peer)}
|
||||
>
|
||||
{replicatingPeerId === peer.peerId ? '⏳' : '🔄'}
|
||||
{replicatingPeerId === peer.peerId ? "⏳" : "🔄"}
|
||||
</button>
|
||||
<button
|
||||
class="action-button"
|
||||
@@ -478,25 +486,31 @@
|
||||
<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'}
|
||||
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) ? '🔔' : '🔕'}
|
||||
{isWatching(peer.peerId) ? "🔔" : "🔕"}
|
||||
</button>
|
||||
</div> <div class="decision-row watch-row">
|
||||
</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'}
|
||||
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) ? '🔗' : '⛓️💥'}
|
||||
{isSyncTarget(peer.name) ? "🔗" : "⛓️💥"}
|
||||
</button>
|
||||
</div> {:else}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="decision-status">
|
||||
<span class="badge status-chip {getAcceptanceStatusClass(peer)}">
|
||||
{getAcceptanceStatus(peer)}
|
||||
@@ -571,7 +585,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.peers-section {
|
||||
@@ -584,7 +597,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -603,8 +616,9 @@
|
||||
}
|
||||
|
||||
.remote-picker {
|
||||
max-width: 14rem;
|
||||
min-width: 8rem;
|
||||
max-width: 10rem;
|
||||
min-width: 1em;
|
||||
flex-shrink: 1;
|
||||
height: 1.9rem;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: 0.4rem;
|
||||
@@ -648,6 +662,7 @@
|
||||
|
||||
.peers-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -873,5 +888,4 @@
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: a0af792b48...61741c1748
@@ -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,
|
||||
@@ -16,7 +18,105 @@ import type { InjectableServiceHub } from "../../lib/src/services/InjectableServ
|
||||
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;
|
||||
@@ -27,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;
|
||||
@@ -69,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),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -137,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) {
|
||||
@@ -147,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) {
|
||||
@@ -160,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";
|
||||
@@ -201,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;
|
||||
@@ -211,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;
|
||||
@@ -289,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,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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
return;
|
||||
}
|
||||
|
||||
const isHidden = document.hidden;
|
||||
const isHidden = activeWindow.document.hidden;
|
||||
if (this.isLastHidden === isHidden) {
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +134,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();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
info!: HTMLDivElement;
|
||||
fileInfo!: HTMLDivElement;
|
||||
showDiff = false;
|
||||
diffOnly = false;
|
||||
id?: DocumentID;
|
||||
|
||||
file: FilePathWithPrefix;
|
||||
@@ -70,6 +71,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 +98,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) {
|
||||
@@ -151,17 +164,48 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
|
||||
appendTextDiff(diff: [number, string][]) {
|
||||
let hasOmitted = false;
|
||||
for (const [operation, text] of diff) {
|
||||
if (operation == DIFF_DELETE) {
|
||||
this.contentView.createSpan({ text, cls: "history-deleted" });
|
||||
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-deleted" }), text);
|
||||
hasOmitted = false;
|
||||
} else if (operation == DIFF_EQUAL) {
|
||||
this.contentView.createSpan({ text, cls: "history-normal" });
|
||||
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.contentView.createSpan({ text, cls: "history-added" });
|
||||
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" });
|
||||
@@ -258,7 +302,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.contentView.appendText(w1data);
|
||||
this.appendSearchHighlightedText(this.contentView, w1data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +310,11 @@ export class DocumentHistoryModal extends Modal {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +342,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,9 +353,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();
|
||||
@@ -319,6 +368,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() {
|
||||
@@ -327,6 +487,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) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
this.searchTimeout = window.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");
|
||||
|
||||
@@ -342,24 +538,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");
|
||||
|
||||
@@ -27,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);
|
||||
@@ -47,7 +50,7 @@ export class ConflictResolveModal extends Modal {
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
const span = container.createSpan({ cls });
|
||||
span.textContent = line;
|
||||
span.setText(line);
|
||||
if (index < lines.length - 1) {
|
||||
container.createSpan({ cls: "ls-mark-cr" });
|
||||
container.createEl("br");
|
||||
@@ -62,6 +65,33 @@ export class ConflictResolveModal extends Modal {
|
||||
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
|
||||
@@ -78,10 +108,26 @@ 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");
|
||||
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];
|
||||
@@ -91,12 +137,11 @@ export class ConflictResolveModal extends Modal {
|
||||
continue;
|
||||
}
|
||||
if (x1 == DIFF_DELETE) {
|
||||
this.appendDiffFragment(div, x2, "deleted");
|
||||
div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
|
||||
this.appendDiffFragment(this.diffView, x2, "deleted");
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
this.appendDiffFragment(div, x2, "normal");
|
||||
this.appendDiffFragment(this.diffView, x2, "normal");
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
this.appendDiffFragment(div, x2, "added");
|
||||
this.appendDiffFragment(this.diffView, x2, "added");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,24 +153,30 @@ export class ConflictResolveModal extends Modal {
|
||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
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";
|
||||
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";
|
||||
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) {
|
||||
div.empty();
|
||||
div.innerText = "(Too large diff to display)";
|
||||
this.diffView.empty();
|
||||
this.diffView.setText("(Too large diff to display)");
|
||||
}
|
||||
this.resetDiffNavigation();
|
||||
this.navigateDiff("next");
|
||||
}
|
||||
|
||||
sendResponse(result: MergeDialogResult) {
|
||||
|
||||
@@ -25,7 +25,7 @@ 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";
|
||||
@@ -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);
|
||||
@@ -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,7 +461,7 @@ 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();
|
||||
@@ -421,6 +478,8 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
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 +503,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();
|
||||
@@ -469,6 +529,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 +548,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) {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -39,6 +39,7 @@ import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts";
|
||||
import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { generateReport } from "@/common/reportTool.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 +70,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)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.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";
|
||||
@@ -23,6 +23,7 @@ import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
|
||||
|
||||
/**
|
||||
* User modes for onboarding and setup
|
||||
@@ -194,8 +195,24 @@ export class SetupManager extends AbstractModule {
|
||||
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);
|
||||
}
|
||||
@@ -285,9 +302,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);
|
||||
|
||||
46
styles.css
46
styles.css
@@ -17,6 +17,10 @@
|
||||
min-width: 5em;
|
||||
}
|
||||
|
||||
.conflict-action-button {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.op-scrollable {
|
||||
overflow-y: scroll;
|
||||
/* min-height: 280px; */
|
||||
@@ -521,8 +525,48 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.diff-only-label {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.history-search-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-search-input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.history-search-result-indicator {
|
||||
font-size: 0.8em;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.history-search-progress-indicator {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.history-diff-options-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.history-highlight-diff-container,
|
||||
.history-highlight-diff-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-highlight-diff-label {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.diff-focused {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ const terserOption = {
|
||||
inline: false,
|
||||
join_vars: true,
|
||||
loops: true,
|
||||
passes: 4,
|
||||
passes: 1,
|
||||
reduce_vars: true,
|
||||
reduce_funcs: false,
|
||||
arrows: true,
|
||||
collapse_vars: true,
|
||||
collapse_vars: false,
|
||||
comparisons: true,
|
||||
//@ts-ignore
|
||||
lhs_constants: true,
|
||||
|
||||
69
updates.md
69
updates.md
@@ -3,6 +3,75 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
## 0.25.70
|
||||
|
||||
25th May, 2026
|
||||
|
||||
### New features
|
||||
- Diff dialogue now has great tools to navigate and understand the differences, including:
|
||||
- A checkbox to toggle the visibility of collapsed identical sections, making it easier to focus on the actual differences (PR #889).
|
||||
- A search feature to find specific text in past revisions, and navigate revisions with search results highlighted in the dialogue (PR #890).
|
||||
|
||||
- Conflict resolution dialogue now has a navigation feature to jump between conflicts (PR #891).
|
||||
|
||||
Thank you so much to @SeleiXi for implementing these features!
|
||||
|
||||
### Improved
|
||||
|
||||
- More diagnostic information for P2P connections is now shown, including why a connection failure occurred and the current connection status.
|
||||
|
||||
## 0.25.69
|
||||
|
||||
22nd May, 2026
|
||||
|
||||
### Fixed
|
||||
- No longer does the P2P passphrase mismatch cause a server shutdown.
|
||||
- Settings related to P2P synchronisation are now correctly applied on start-up and no longer reverted.
|
||||
|
||||
### New features
|
||||
- Diagnostic P2P connection stats are now available.
|
||||
- These stats indicate the number of connection trials, successes, and failures.
|
||||
|
||||
## 0.25.68
|
||||
|
||||
22nd May, 2026
|
||||
|
||||
### Improved
|
||||
|
||||
- P2P connections have improved slightly
|
||||
- Upgrade to `trystero` v0.24.0, and fixes event handler assignment. This should fix some edge cases where P2P connections fail to establish or messages are not properly handled.
|
||||
- Weaken terser options to avoid potential issues with minification that could cause runtime errors in some environments.
|
||||
|
||||
## ~~0.25.66~~ 0.25.67
|
||||
|
||||
20th May, 2026
|
||||
|
||||
0.25.66 had a bug that the auto-accept logic for compatible but lossy mismatches was not working as intended.
|
||||
|
||||
### New features
|
||||
- Implement an auto-accept compatible tweak setting and enhance the mismatch resolution logic.
|
||||
|
||||
### Improved
|
||||
- Many messages related to tweak mismatch resolution have been updated for clarity.
|
||||
|
||||
## 0.25.65
|
||||
|
||||
19th May, 2026
|
||||
|
||||
### Fixed
|
||||
- Fix an issue about resuming from background on iOS (#888).
|
||||
- Now Chunk Splitter: `V3: Fine Deduplication` is working fine again (#866).
|
||||
- It has some drawbacks, such as fewer chunks are generated. However, it makes less transfer and storage when the files are modified but not completely changed.
|
||||
- Unsynchronised local changes (which means changes that have not been sent) are now correctly preserved as a conflict (Thank you so much for @SeleiXi!).
|
||||
- Avoid creating a new revision when the current and conflicted revisions have identical content (Thank you so much for @daichi-629).
|
||||
|
||||
### Improved
|
||||
- Improved the error verbosity on concurrent processing during the start-up process.
|
||||
- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`).
|
||||
- Updating logs is now debounced to avoid excessive updates during rapid log generation.
|
||||
- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue.
|
||||
|
||||
|
||||
## 0.25.64
|
||||
|
||||
17th May, 2026
|
||||
|
||||
197
utils/bench/splitPiecesRabinKarp.ts
Normal file
197
utils/bench/splitPiecesRabinKarp.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { glob } from "glob";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { isPlainText, shouldSplitAsPlainText } from "../../src/lib/src/string_and_binary/path";
|
||||
import { splitPiecesRabinKarp } from "../../src/lib/src/string_and_binary/chunks";
|
||||
import {
|
||||
PREFERRED_BASE,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
PREFERRED_SETTING_CLOUDANT,
|
||||
PREFERRED_SETTING_SELF_HOSTED,
|
||||
} from "../../src/lib/src/common/models/setting.const.preferred";
|
||||
import { type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, MAX_DOC_SIZE_BIN } from "../../src/lib/src/common/types";
|
||||
|
||||
async function blobFromString(content: string): Promise<Blob> {
|
||||
return new Blob([content], { type: "text/plain" });
|
||||
}
|
||||
|
||||
const preferred = PREFERRED_BASE;
|
||||
const preferredJournal = PREFERRED_JOURNAL_SYNC;
|
||||
const preferredCouchDB = PREFERRED_SETTING_SELF_HOSTED;
|
||||
const preferredIBM = PREFERRED_SETTING_CLOUDANT;
|
||||
|
||||
function computeChunkSize(overlay: Partial<ObsidianLiveSyncSettings>) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...overlay };
|
||||
const maxChunkSize = Math.floor(MAX_DOC_SIZE_BIN * ((settings.customChunkSize || 0) * 1 + 1));
|
||||
const pieceSize = maxChunkSize;
|
||||
|
||||
const minimumChunkSize = settings.minimumChunkSize;
|
||||
return { pieceSize, minimumChunkSize };
|
||||
}
|
||||
|
||||
async function testSplit(
|
||||
splitPiecesRabinKarpFn: typeof splitPiecesRabinKarp,
|
||||
content: Blob,
|
||||
settingsOverlay: Partial<ObsidianLiveSyncSettings>
|
||||
) {
|
||||
const { pieceSize, minimumChunkSize } = computeChunkSize(settingsOverlay);
|
||||
const isPlain = content.type === "text/plain";
|
||||
const chunkGenerator = await splitPiecesRabinKarpFn(content, pieceSize, isPlain, minimumChunkSize);
|
||||
const chunks = [] as string[];
|
||||
for await (const chunk of chunkGenerator()) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
// if there are few chunks, calculate average chunk size except the last chunk which can be smaller due to the way the algorithm works, especially for small files.
|
||||
const averageChunkSize =
|
||||
chunks.length > 1
|
||||
? chunks.slice(0, -1).reduce((acc, chunk) => acc + chunk.length, 0) / (chunks.length - 1)
|
||||
: chunks.reduce((acc, chunk) => acc + chunk.length, 0) / chunks.length;
|
||||
const lastChunk = chunks[chunks.length - 1];
|
||||
// compute minimum chunk size if the last chunk is not the smallest.
|
||||
const nonLastChunkSizes = chunks.slice(0, -1).map((c) => c.length);
|
||||
const minChunkSize = nonLastChunkSizes.length > 0 ? Math.min(...nonLastChunkSizes) : lastChunk.length;
|
||||
const result = {
|
||||
isPlain,
|
||||
originalSize: content.size,
|
||||
chunkCount: chunks.length,
|
||||
totalLength: chunks.reduce((acc, chunk) => acc + chunk.length, 0),
|
||||
averageChunkSize: averageChunkSize,
|
||||
maxChunkSize: Math.max(...chunks.map((c) => c.length)),
|
||||
minChunkSize: minChunkSize,
|
||||
uniqueChunks: new Set(chunks).size,
|
||||
chunks: chunks,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, "..");
|
||||
async function loadFileAsBlob(filePath: string): Promise<Blob> {
|
||||
if (shouldSplitAsPlainText(filePath)) {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
return blobFromString(content);
|
||||
} else {
|
||||
const buffer = await fs.readFile(filePath);
|
||||
return new Blob([buffer]);
|
||||
}
|
||||
}
|
||||
const testProfiles = [
|
||||
{ name: "CouchDB", settings: preferredCouchDB },
|
||||
{ name: "IBM Cloudant", settings: preferredIBM },
|
||||
{ name: "Journal Sync", settings: preferredJournal },
|
||||
// { name: "Base", settings: preferred },
|
||||
];
|
||||
function modifyBlob(blob: Blob, position: number, insertText: string): Blob {
|
||||
const before = blob.slice(0, position);
|
||||
const after = blob.slice(position);
|
||||
const insert = new Blob([insertText], { type: blob.type });
|
||||
return new Blob([before, insert, after], { type: blob.type });
|
||||
}
|
||||
async function main() {
|
||||
const results = [] as string[][];
|
||||
console.log("directory:", __dirname);
|
||||
const findPath = resolve(__dirname, "../../");
|
||||
console.warn("CWD:", findPath);
|
||||
let testFiles = await glob("**/*.*", {
|
||||
cwd: findPath,
|
||||
maxDepth: 20,
|
||||
ignore: ["**/node_modules/**", "**/.obsidian/**", "**/dist/**", "**/build/**", "**/out/**"],
|
||||
});
|
||||
testFiles = testFiles.filter((file) => {
|
||||
const ext = file.split(".").pop()?.toLowerCase() || "";
|
||||
return ["md", "txt", "json", "csv", "png"].includes(ext);
|
||||
});
|
||||
const header = [
|
||||
"Profile",
|
||||
"Implementation",
|
||||
"Edition",
|
||||
"File",
|
||||
"Mode",
|
||||
"Original Size (bytes)",
|
||||
"Chunk Count",
|
||||
"Average Chunk Size",
|
||||
"Max Chunk Size",
|
||||
"Min Chunk Size",
|
||||
"Unique Chunks",
|
||||
"Shared Chunks",
|
||||
"Savings",
|
||||
"Newly added (count)",
|
||||
"Newly consumed (bytes)",
|
||||
];
|
||||
for (const profile of testProfiles) {
|
||||
console.log(`Testing profile: ${profile.name}`);
|
||||
for (const fn of [splitPiecesRabinKarp]) {
|
||||
const funcProfile = fn !== splitPiecesRabinKarp ? "Old" : "New";
|
||||
console.log(`Testing function: ${funcProfile}`);
|
||||
for (const file of testFiles) {
|
||||
const filePath = resolve(findPath, file);
|
||||
const isPlain = shouldSplitAsPlainText(filePath);
|
||||
const content = await loadFileAsBlob(filePath);
|
||||
console.log(`Testing file: ${file} (size: ${content.size} bytes)`);
|
||||
const result = await testSplit(fn, content, profile.settings);
|
||||
const chunkSizes = result.chunks.map((c) => c.length);
|
||||
const savings = result.originalSize - chunkSizes.reduce((acc, size) => acc + size, 0);
|
||||
// console.log(`Result for ${file}:`, result);
|
||||
results.push([
|
||||
`${profile.name}`,
|
||||
funcProfile,
|
||||
"original",
|
||||
file,
|
||||
isPlain ? "plain" : "binary",
|
||||
content.size.toString(),
|
||||
result.chunkCount.toString(),
|
||||
result.averageChunkSize.toFixed(2),
|
||||
result.maxChunkSize.toString(),
|
||||
result.minChunkSize.toString(),
|
||||
result.uniqueChunks.toString(),
|
||||
"",
|
||||
savings.toString(),
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
// add editions (inserting "*") to content on head, 5%, middle, 95%, tail to see if it affects the chunking
|
||||
const editions = [
|
||||
{ name: "head", content: modifyBlob(content, 0, "*") },
|
||||
{ name: "5%", content: modifyBlob(content, Math.floor(content.size * 0.05), "*") },
|
||||
{ name: "middle", content: modifyBlob(content, Math.floor(content.size * 0.5), "*") },
|
||||
{ name: "95%", content: modifyBlob(content, Math.floor(content.size * 0.95), "*") },
|
||||
{ name: "tail", content: modifyBlob(content, content.size, "*") },
|
||||
];
|
||||
const baseChunks = result.chunks;
|
||||
for (const edition of editions) {
|
||||
console.log(`Testing edition: ${edition.name}`);
|
||||
const editionResult = await testSplit(fn, edition.content, profile.settings);
|
||||
const sharedChunks = editionResult.chunks.filter((chunk) => baseChunks.includes(chunk)).length;
|
||||
const newChunks = editionResult.chunks.filter((chunk) => !baseChunks.includes(chunk));
|
||||
const editionResultChunkLength = editionResult.chunks.map((c) => c.length);
|
||||
// console.log(`Result for edition ${edition.name} of ${file}:`, editionResult);
|
||||
const editionSavings =
|
||||
editionResult.originalSize - editionResultChunkLength.reduce((acc, size) => acc + size, 0);
|
||||
// newly added chunks size :
|
||||
const newChunksSize = newChunks.reduce((acc, chunk) => acc + chunk.length, 0);
|
||||
results.push([
|
||||
`${profile.name}`,
|
||||
funcProfile,
|
||||
`${edition.name}`,
|
||||
file,
|
||||
isPlain ? "plain" : "binary",
|
||||
edition.content.size.toString(),
|
||||
editionResult.chunkCount.toString(),
|
||||
editionResult.averageChunkSize.toFixed(2),
|
||||
editionResult.maxChunkSize.toString(),
|
||||
editionResult.minChunkSize.toString(),
|
||||
editionResult.uniqueChunks.toString(),
|
||||
sharedChunks.toString(),
|
||||
editionSavings.toString(),
|
||||
newChunks.length.toString(),
|
||||
newChunksSize.toString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.unshift(header);
|
||||
await fs.writeFile(resolve(__dirname, "splitResults.csv"), results.map((r) => r.join(",")).join("\n"));
|
||||
}
|
||||
main();
|
||||
Reference in New Issue
Block a user