diff --git a/.github/ISSUE_TEMPLATE/issue-report.md b/.github/ISSUE_TEMPLATE/issue-report.md index d12287f..0184d93 100644 --- a/.github/ISSUE_TEMPLATE/issue-report.md +++ b/.github/ISSUE_TEMPLATE/issue-report.md @@ -53,11 +53,12 @@ The hatch report (below) includes version information. If you cannot provide the - Self-hosted LiveSync version: -### 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.
-Report from hatch (primary) +Report and Logs (primary) ``` @@ -65,29 +66,7 @@ Open the `Hatch` pane in LiveSync settings and press `Make report`. Paste here o
-Report from hatch (if applicable) - -``` - -``` -
- - -### 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. - -
-Plug-in log (primary) - -``` - -``` -
- - -
-Plug-in log (if applicable) +Report and Logs (if applicable) ``` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0720e6..408b6dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/.github/workflows/unit-ci.yml b/.github/workflows/unit-ci.yml index 2307e82..f9f885b 100644 --- a/.github/workflows/unit-ci.yml +++ b/.github/workflows/unit-ci.yml @@ -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' diff --git a/.gitignore b/.gitignore index 43e267e..a6bced9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,6 @@ data.json cov_profile/** coverage -src/apps/cli/dist/* \ No newline at end of file +src/apps/cli/dist/* +_testdata/** +utils/bench/splitResults.csv \ No newline at end of file diff --git a/README.md b/README.md index 9980ca7..ed2540e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Self-hosted LiveSync is a community-developed synchronisation plug-in available on all Obsidian-compatible platforms. It leverages robust server solutions such as CouchDB or object storage systems (e.g., MinIO, S3, R2, etc.) to ensure reliable data synchronisation. -Additionally, it supports peer-to-peer synchronisation using WebRTC now (experimental), enabling you to synchronise your notes directly between devices without relying on a server. +Additionally, it supports peer-to-peer synchronisation using WebRTC, enabling you to synchronise your notes directly between devices without relying on a server. Documentations is available for [Peer-to-Peer Synchronisation](./docs/p2p_sync_updates_2026.md). ![obsidian_live_sync_demo](https://user-images.githubusercontent.com/45774780/137355323-f57a8b09-abf2-4501-836c-8cb7d2ff24a3.gif) diff --git a/docs/p2p_sync_updates_2026.md b/docs/p2p_sync_updates_2026.md new file mode 100644 index 0000000..b6f3903 --- /dev/null +++ b/docs/p2p_sync_updates_2026.md @@ -0,0 +1,59 @@ +# User Guide: Peer-to-Peer Synchronisation (2026 Edition) + +Peer-to-Peer (P2P) synchronisation has evolved significantly. This guide covers the essential setup and the new features introduced in the 2026 updates. + +## 1. Core Concept: Server-less Freedom +P2P synchronisation allows your devices to talk directly to each other using WebRTC. A central server is not required for data storage, ensuring maximum privacy and "freedom." + +## 2. Setting Up via P2P Status Pane +You no longer need to navigate through complex menus. Simply open the **P2P Status** (via the ribbon icon or command palette) and click the **⚙ (Cog)** icon. + +This opens the **P2P Setup** dialogue where you can configure the essentials: +- **Room ID:** A unique identifier for your synchronisation group. +- **Passphrase:** Your encryption key. Ensure all your devices use the exact same passphrase. +- **Device Name:** A recognisable name for the current device (e.g., `iphone-16`). + +Once you have saved the settings, return to the **P2P Status Pane** and click the **Connect** button to join the network. + +*Tip: You can also toggle **Auto Connect** in the setup dialogue to automatically join the network whenever Obsidian starts.* + +## 3. Real-time Control +The status pane in the right sidebar provides granular control over your synchronisation: + +- **Active P2P Remote (new):** P2P now has its own active remote selection, separate from the normal active remote for database replication. Use the combo box next to the cog icon to choose which P2P remote configuration is active for P2P features. +- **Create P2P Remote (new):** Use the **+** button to open the P2P setup dialogue and create a dedicated P2P remote configuration. This is recommended when no P2P active remote has been selected yet. +- **Selection required (new):** If no P2P active remote is selected, the pane asks for selection before P2P target-related changes are saved. + +- **Signalling Status:** Shows if you are connected to the relay (🟢 Online). +- **Live-push (Broadcast):** Toggle "Broadcast changes" to notify other peers whenever you make an edit. +- **Replicate now (🔄):** Start immediate bidirectional replication with a visible peer (Pull, then Push). +- **Watch (🔔/🔕):** Enable "Watch" on specific peers to automatically pull changes when they broadcast. This creates a "LiveSync-like" experience. +- **Sync target (🔗/⛓️‍💥):** Mark specific peers as **sync targets**. Peers marked here will be included when you run the **"P2P: Sync with targets"** command (see section 5). Click the button next to a peer to toggle it on (🔗, highlighted) or off (⛓️‍💥). This setting is persisted in your configuration. + +## 4. Replication Dialogue +If you want to synchronise with a specific peer manually, use the **Replication** command or button. This opens the **Replication Dialogue** listing available devices. + +Inside the dialogue, the **Server Status** card at the top confirms you are still connected while performing the sync. +The status card now shows a stable **Room ID suffix** above **Peer ID**. The Room ID suffix is better for identifying your P2P group, while Peer ID may change between connections. + +Two actions are available per peer: + +- **Sync** — Starts a bidirectional synchronisation (Pull then Push) and keeps the dialogue open so you can monitor progress or sync with additional peers. +- **Start Sync & Close** — Starts the same bidirectional sync in the background and **immediately closes the dialogue**, so you can continue working without waiting. + +## 5. Syncing with Registered Targets via Command Palette + +You can now trigger a synchronisation with all your pre-registered target peers in one step, without opening any UI. + +1. Open the **Command Palette** (`Ctrl/Cmd + P`). +2. Run **"P2P: Sync with targets"**. + +This command synchronises with every peer whose **SYNC** toggle is enabled in the **Detected Peers** list. If no targets are registered, or if the P2P server is not running, the command will notify you accordingly. + +*Tip: Pair this command with a hotkey for a quick, keyboard-driven sync workflow.* + +## 6. Technical Improvements in 2026 +- **Decoupled Architecture:** The UI is now strictly separated from the core logic, making the plugin more stable across different platforms (Mobile, Desktop, and Web). +- **Svelte 5 UI:** The interface has been rebuilt for better responsiveness and clearer status indicators. +- **Security:** All data remains end-to-end encrypted. Even the signalling relay never sees your actual notes. + diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fe22a00..f463e36 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. ![Screenshot](../images/hatch.png) +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. diff --git a/esbuild.config.mjs b/esbuild.config.mjs index fe62f52..4350fab 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -2,7 +2,6 @@ import esbuild from "esbuild"; import process from "process"; -import builtins from "builtin-modules"; import sveltePlugin from "esbuild-svelte"; import { sveltePreprocess } from "svelte-preprocess"; import fs from "node:fs"; diff --git a/eslint.config.mjs b/eslint.config.mjs index 41cb90b..35b0d25 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,103 +1,83 @@ -import typescriptEslint from "@typescript-eslint/eslint-plugin"; -import svelte from "eslint-plugin-svelte"; -import _import from "eslint-plugin-import"; -import { fixupPluginRules } from "@eslint/compat"; import tsParser from "@typescript-eslint/parser"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import js from "@eslint/js"; -import { FlatCompat } from "@eslint/eslintrc"; +import obsidianmd from "eslint-plugin-obsidianmd"; +import globals from "globals"; +import { defineConfig, globalIgnores } from "eslint/config"; +import * as sveltePlugin from "eslint-plugin-svelte"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all, -}); - -export default [ +export default defineConfig([ + globalIgnores([ + "**/node_modules/*", + "**/jest.config.js", + "src/lib/coverage", + "src/lib/browsertest", + "**/test.ts", + "**/tests.ts", + "**/**test.ts", + "**/**.test.ts", + "**/*.unit.spec.ts", + "**/esbuild.*.mjs", + "**/terser.*.mjs", + "**/node_modules", + "**/build", + "**/.eslintrc.js.bak", + "src/lib/src/patches/pouchdb-utils", + "**/esbuild.config.mjs", + "**/rollup.config.js", + "modules/octagonal-wheels/rollup.config.js", + "modules/octagonal-wheels/dist/**/*", + "src/lib/test", + "src/lib/_tools", + "src/lib/src/cli", + "**/main.js", + "src/apps/**/*", + ".prettierrc.*.mjs", + ".prettierrc.mjs", + "*.config.mjs", + "src/apps/**/*", + "src/lib/src/services/implements/browser/**", + "src/lib/src/services/implements/headless/**", + "src/lib/src/API", + ]), + ...sveltePlugin.configs["flat/base"], + ...obsidianmd.configs.recommended, { - ignores: [ - "**/node_modules/*", - "**/jest.config.js", - "src/lib/coverage", - "src/lib/browsertest", - "**/test.ts", - "**/tests.ts", - "**/**test.ts", - "**/**.test.ts", - "**/esbuild.*.mjs", - "**/terser.*.mjs", - "**/node_modules", - "**/build", - "**/.eslintrc.js.bak", - "src/lib/src/patches/pouchdb-utils", - "**/esbuild.config.mjs", - "**/rollup.config.js", - "modules/octagonal-wheels/rollup.config.js", - "modules/octagonal-wheels/dist/**/*", - "src/lib/test", - "src/lib/_tools", - "src/lib/src/cli", - "**/main.js", - "src/apps/**/*", - ".prettierrc.*.mjs", - ".prettierrc.mjs", - "*.config.mjs" - ], - }, - ...compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended" - ), - { - plugins: { - "@typescript-eslint": typescriptEslint, - svelte, - import: fixupPluginRules(_import), - }, - + files: ["**/*.ts"], languageOptions: { + globals: { ...globals.browser }, parser: tsParser, - ecmaVersion: 5, - sourceType: "module", - parserOptions: { - project: ["tsconfig.json"], + project: "./tsconfig.json", }, }, - rules: { "no-unused-vars": "off", - - "@typescript-eslint/no-unused-vars": [ - "error", - { - args: "none", - }, - ], - + "@typescript-eslint/no-unused-vars": ["error", { args: "none" }], "no-unused-labels": "off", "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", "@typescript-eslint/no-empty-function": "off", "require-await": "error", + "obsidianmd/rule-custom-message": "off", // Temporary + "obsidianmd/ui/sentence-case": "off", // Temporary "@typescript-eslint/require-await": "warn", "@typescript-eslint/no-misused-promises": "warn", "@typescript-eslint/no-floating-promises": "warn", "no-async-promise-executor": "warn", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unnecessary-type-assertion": "error", - - "no-constant-condition": [ - "error", - { - checkLoops: false, - }, - ], + "no-constant-condition": ["error", { checkLoops: false }], }, }, -]; - + { + files: ["**/*.svelte"], + languageOptions: { + parserOptions: { + parser: tsParser, + }, + }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "obsidianmd/no-plugin-as-component": "off", // Temporary + }, + }, +]); diff --git a/manifest.json b/manifest.json index cb9b9f1..09d6480 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "obsidian-livesync", "name": "Self-hosted LiveSync", - "version": "0.25.60", - "minAppVersion": "0.9.12", + "version": "0.25.69", + "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 -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0f1f682..e707b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-livesync", - "version": "0.25.60", + "version": "0.25.69", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-livesync", - "version": "0.25.60", + "version": "0.25.69", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", @@ -15,29 +15,31 @@ "@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", "fflate": "^0.8.2", "idb": "^8.0.3", "markdown-it": "^14.1.1", + "micromatch": "^4.0.0", "minimatch": "^10.2.2", + "obsidian": "^1.12.3", "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": { "@chialab/esbuild-plugin-worker": "^0.19.0", - "@eslint/compat": "^2.0.2", - "@eslint/eslintrc": "^3.3.4", "@eslint/js": "^9.39.3", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tsconfig/svelte": "^5.0.8", "@types/deno": "^2.5.0", "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", + "@types/micromatch": "^4.0.10", "@types/node": "^24.10.13", "@types/pouchdb": "^6.4.2", "@types/pouchdb-adapter-http": "^6.1.6", @@ -52,18 +54,15 @@ "@vitest/browser": "^4.1.1", "@vitest/browser-playwright": "^4.1.1", "@vitest/coverage-v8": "^4.1.1", - "builtin-modules": "5.0.0", - "dotenv": "^17.3.1", "dotenv-cli": "^11.0.0", "esbuild": "0.25.0", "esbuild-plugin-inline-worker": "^0.1.1", "esbuild-svelte": "^0.9.4", "eslint": "^9.39.3", - "eslint-plugin-import": "^2.32.0", + "eslint-plugin-obsidianmd": "^0.3.0", "eslint-plugin-svelte": "^3.15.0", "events": "^3.3.0", - "glob": "^13.0.6", - "obsidian": "^1.12.3", + "globals": "^14.0.0", "playwright": "^1.58.2", "postcss": "^8.5.6", "postcss-load-config": "^6.0.1", @@ -84,6 +83,7 @@ "svelte-check": "^4.4.3", "svelte-preprocess": "^6.0.3", "terser": "^5.39.0", + "tinyglobby": "^0.2.15", "transform-pouch": "^2.0.0", "tslib": "^2.8.1", "tsx": "^4.21.0", @@ -1338,7 +1338,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1349,7 +1348,6 @@ "version": "6.38.6", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -1830,27 +1828,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/compat": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.0.3.tgz", - "integrity": "sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "peerDependencies": { - "eslint": "^8.40 || 9 || 10" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, "node_modules/@eslint/config-array": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", @@ -1874,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": { @@ -1910,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==", @@ -1923,19 +1900,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/core": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz", - "integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", @@ -1968,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": { @@ -2004,6 +1968,22 @@ "url": "https://eslint.org/donate" } }, + "node_modules/@eslint/json": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz", + "integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "@eslint/plugin-kit": "^0.4.1", + "@humanwhocodes/momoa": "^3.3.10", + "natural-compare": "^1.4.0" + }, + "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", @@ -2028,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", @@ -2101,6 +2068,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/momoa": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz", + "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -2377,9 +2354,56 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", - "dev": true, "license": "MIT" }, + "node_modules/@microsoft/eslint-plugin-sdl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@microsoft/eslint-plugin-sdl/-/eslint-plugin-sdl-1.1.0.tgz", + "integrity": "sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-n": "17.10.3", + "eslint-plugin-react": "7.37.3", + "eslint-plugin-security": "1.4.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "eslint": "^9" + } + }, + "node_modules/@microsoft/eslint-plugin-sdl/node_modules/eslint-plugin-security": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz", + "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^1.1.0" + } + }, + "node_modules/@microsoft/eslint-plugin-sdl/node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/@microsoft/eslint-plugin-sdl/node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, "node_modules/@minhducsun2002/leb128": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz", @@ -2660,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/" @@ -3002,6 +3026,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", + "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3509,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": { @@ -3689,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": { @@ -3957,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" @@ -4021,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": { @@ -4163,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": { @@ -4266,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": { @@ -4298,6 +4329,13 @@ "@babel/types": "^7.0.0" } }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4313,7 +4351,6 @@ "version": "5.60.8", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", - "dev": true, "license": "MIT", "dependencies": { "@types/tern": "*" @@ -4350,11 +4387,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/fs-extra": { @@ -4417,6 +4464,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/micromatch": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.10.tgz", + "integrity": "sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -4432,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": { @@ -4649,7 +4706,6 @@ "version": "0.23.9", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "*" @@ -5167,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": { @@ -5289,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": { @@ -5319,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": { @@ -5444,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": { @@ -5535,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": { @@ -5756,6 +5812,27 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", @@ -5816,6 +5893,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", @@ -6085,9 +6179,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz", - "integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "dev": true, "license": "MIT", "engines": { @@ -6108,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" @@ -6123,7 +6217,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6207,29 +6300,16 @@ "dev": true, "license": "MIT" }, - "node_modules/builtin-modules": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", - "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -6385,7 +6465,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -6647,7 +6726,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -7040,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": { @@ -7206,6 +7284,16 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encoding-down": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", @@ -7254,6 +7342,20 @@ "write-stream": "~0.4.3" } }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -7279,9 +7381,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -7367,6 +7469,34 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -7610,6 +7740,22 @@ } } }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -7660,6 +7806,40 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-depend": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-depend/-/eslint-plugin-depend-1.3.1.tgz", + "integrity": "sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "empathic": "^2.0.0", + "module-replacements": "^2.8.0", + "semver": "^7.6.3" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -7702,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": { @@ -7745,6 +7925,354 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-json-schema-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-5.1.0.tgz", + "integrity": "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.3.0", + "ajv": "^8.0.0", + "debug": "^4.3.1", + "eslint-compat-utils": "^0.5.0", + "json-schema-migrate": "^2.0.0", + "jsonc-eslint-parser": "^2.0.0", + "minimatch": "^8.0.0", + "synckit": "^0.9.0", + "toml-eslint-parser": "^0.9.0", + "tunnel-agent": "^0.6.0", + "yaml-eslint-parser": "^1.0.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-n": { + "version": "17.10.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.10.3.tgz", + "integrity": "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "enhanced-resolve": "^5.17.0", + "eslint-plugin-es-x": "^7.5.0", + "get-tsconfig": "^4.7.0", + "globals": "^15.8.0", + "ignore": "^5.2.4", + "minimatch": "^9.0.5", + "semver": "^7.5.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.23.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.5.tgz", + "integrity": "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-obsidianmd": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.3.0.tgz", + "integrity": "sha512-QvGDI6B2nxJBrsZKGTg31da2A/fEJNlnwN+fRZkaoPIu1QL3fYXUdpP7ThyMdr/0iTYQxifb9lt2X9cpydQx1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/config-helpers": "^0.4.2", + "@eslint/js": "^9.30.1", + "@eslint/json": "0.14.0", + "@microsoft/eslint-plugin-sdl": "^1.1.0", + "@types/eslint": "9.6.1", + "@types/node": "20.12.12", + "@typescript-eslint/types": "^8.33.1", + "@typescript-eslint/utils": "^8.33.1", + "eslint": ">=9.0.0", + "eslint-plugin-depend": "1.3.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-json-schema-validator": "5.1.0", + "eslint-plugin-no-unsanitized": "^4.1.5", + "eslint-plugin-security": "2.1.1", + "globals": "14.0.0", + "obsidian": "1.12.3", + "semver": "^7.7.4", + "typescript": "5.4.5", + "typescript-eslint": "^8.35.1" + }, + "bin": { + "eslint-plugin-obsidian": "dist/lib/index.js" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@eslint/js": "^9.30.1", + "@eslint/json": "0.14.0", + "eslint": ">=9.0.0", + "obsidian": "1.8.7", + "typescript-eslint": "^8.35.1" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", + "integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "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": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "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.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-security": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.1.tgz", + "integrity": "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + } + }, "node_modules/eslint-plugin-svelte": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.16.0.tgz", @@ -7872,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", @@ -7893,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": { @@ -8166,10 +8681,27 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -8178,7 +8710,8 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { @@ -8255,7 +8788,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8712,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": { @@ -8866,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": { @@ -9092,9 +9624,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "dev": true, "license": "MIT", "engines": { @@ -9209,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" @@ -9358,7 +9890,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9673,6 +10204,24 @@ "node": ">=8" } }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -9740,6 +10289,40 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-migrate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz", + "integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + } + }, + "node_modules/json-schema-migrate/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/json-schema-migrate/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -9767,6 +10350,43 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonc-eslint-parser": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz", + "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.5.0", + "eslint-visitor-keys": "^3.0.0", + "espree": "^9.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -9777,6 +10397,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -10271,10 +10907,30 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "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": { @@ -10409,7 +11065,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10494,11 +11149,17 @@ "node": ">=18.0.0" } }, + "node_modules/module-replacements": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz", + "integrity": "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==", + "dev": true, + "license": "MIT" + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -10591,6 +11252,35 @@ "node": ">= 0.4.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-fetch": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz", @@ -10652,6 +11342,16 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -10696,6 +11396,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object.fromentries": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", @@ -10753,7 +11469,6 @@ "version": "1.12.3", "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", - "dev": true, "license": "MIT", "dependencies": { "@types/codemirror": "5.60.8", @@ -11119,7 +11834,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -11744,6 +12458,18 @@ "node": ">=0.4.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -11885,6 +12611,13 @@ ], "license": "MIT" }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -11917,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": { @@ -11943,7 +12676,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -11982,6 +12714,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -12013,6 +12755,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -12020,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" @@ -12263,6 +13016,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -12312,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": { @@ -12817,6 +13580,45 @@ "node": ">=8" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -12955,7 +13757,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "dev": true, "license": "MIT" }, "node_modules/sublevel-pouchdb": { @@ -13188,6 +13989,37 @@ } } }, + "node_modules/synckit": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz", + "integrity": "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar-stream": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", @@ -13358,7 +14190,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -13367,6 +14198,22 @@ "node": ">=8.0" } }, + "node_modules/toml-eslint-parser": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", + "integrity": "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -13970,6 +14817,19 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -14095,6 +14955,274 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -14121,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": { @@ -14985,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" }, @@ -15014,7 +16142,6 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "dev": true, "license": "MIT" }, "node_modules/wait-port": { @@ -15069,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": { @@ -15079,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": { @@ -15141,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": { @@ -15164,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", @@ -15607,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": { @@ -15628,6 +16755,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -15662,12 +16804,11 @@ "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", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15678,6 +16819,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yaml-eslint-parser": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.2.tgz", + "integrity": "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 479780a..35f4221 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-livesync", - "version": "0.25.60", + "version": "0.25.69", "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", @@ -54,21 +54,21 @@ "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "test:docker-all:stop": "npm run test:docker-all:down", "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", - "test:p2p": "bash test/suitep2p/run-p2p-tests.sh" + "test:p2p": "bash test/suitep2p/run-p2p-tests.sh", + "version": "node version-bump.mjs && git add manifest.json versions.json" }, "keywords": [], "author": "vorotamoroz", "license": "MIT", "devDependencies": { "@chialab/esbuild-plugin-worker": "^0.19.0", - "@eslint/compat": "^2.0.2", - "@eslint/eslintrc": "^3.3.4", "@eslint/js": "^9.39.3", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tsconfig/svelte": "^5.0.8", "@types/deno": "^2.5.0", "@types/diff-match-patch": "^1.0.36", "@types/markdown-it": "^14.1.2", + "@types/micromatch": "^4.0.10", "@types/node": "^24.10.13", "@types/pouchdb": "^6.4.2", "@types/pouchdb-adapter-http": "^6.1.6", @@ -83,18 +83,15 @@ "@vitest/browser": "^4.1.1", "@vitest/browser-playwright": "^4.1.1", "@vitest/coverage-v8": "^4.1.1", - "builtin-modules": "5.0.0", - "dotenv": "^17.3.1", "dotenv-cli": "^11.0.0", "esbuild": "0.25.0", "esbuild-plugin-inline-worker": "^0.1.1", "esbuild-svelte": "^0.9.4", "eslint": "^9.39.3", - "eslint-plugin-import": "^2.32.0", + "eslint-plugin-obsidianmd": "^0.3.0", "eslint-plugin-svelte": "^3.15.0", "events": "^3.3.0", - "glob": "^13.0.6", - "obsidian": "^1.12.3", + "globals": "^14.0.0", "playwright": "^1.58.2", "postcss": "^8.5.6", "postcss-load-config": "^6.0.1", @@ -115,6 +112,7 @@ "svelte-check": "^4.4.3", "svelte-preprocess": "^6.0.3", "terser": "^5.39.0", + "tinyglobby": "^0.2.15", "transform-pouch": "^2.0.0", "tslib": "^2.8.1", "tsx": "^4.21.0", @@ -132,17 +130,20 @@ "@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", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", "markdown-it": "^14.1.1", + "micromatch": "^4.0.0", "minimatch": "^10.2.2", "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" } } diff --git a/src/LiveSyncBaseCore.ts b/src/LiveSyncBaseCore.ts index f9f0427..9a49451 100644 --- a/src/LiveSyncBaseCore.ts +++ b/src/LiveSyncBaseCore.ts @@ -1,4 +1,5 @@ import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; +import type PouchDB from "pouchdb-core"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types"; import { __$checkInstanceBinding } from "./lib/src/dev/checks"; @@ -123,7 +124,7 @@ export class LiveSyncBaseCore< for (const module of this.modules) { if (module.constructor === constructor) return module as T; } - throw new Error(`Module ${constructor} not found or not loaded.`); + throw new Error(`Module ${constructor.name} not found or not loaded.`); } /** @@ -160,8 +161,10 @@ export class LiveSyncBaseCore< module.onBindFunction(this, this.services); __$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not. } else { + // module should not be never. + const moduleName = (module as unknown)?.constructor?.name ?? "unknown"; this.services.API.addLog( - `Module ${(module as any)?.constructor?.name ?? "unknown"} does not have onBindFunction, skipping binding.`, + `Module ${moduleName} does not have onBindFunction, skipping binding.`, LOG_LEVEL_INFO ); } diff --git a/src/apps/cli/Dockerfile b/src/apps/cli/Dockerfile index 7e85bdb..beed3b5 100644 --- a/src/apps/cli/Dockerfile +++ b/src/apps/cli/Dockerfile @@ -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 diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 6bd0082..18f70b7 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -74,6 +74,12 @@ livesync-cli [database-path] [command] [args...] - `pull `: Pull a file `` from the database into local file ``. - `cat `: Read a file from the database and write to stdout. - `put `: Read from stdin and write to the database path ``. +- `remote-add `: Add a remote configuration from a connection string. +- `remote-rm `: Remove a remote configuration by ID. +- `remote-ls`: List remote configurations (`id`, `name`, `active/inactive`, redacted URI). +- `remote-export `: Export the stored connection string by remote ID. +- `remote-set `: Replace the stored connection string by remote ID. +- `remote-activate `: Activate a remote configuration by ID. - `init-settings [file]`: Create a default settings file. ### Examples @@ -92,39 +98,39 @@ livesync-cli ./my-db pull folder/note.md ./note.md ## Installation -### Build from source - -```bash -# Clone with submodules, because the shared core lives in src/lib -git clone --recurse-submodules -cd obsidian-livesync - -# If you already cloned without submodules, run this once instead -git submodule update --init --recursive - -# Install dependencies from the repository root -npm install - -# Build the CLI from its package directory -cd src/apps/cli -npm run build -``` - -If `src/lib` is missing, `npm run build` now stops early with a targeted message -instead of a low-level Vite `ENOENT` error. +### Build from source -Run the CLI: - -```bash -# Run with npm script (from repository root) -npm run --silent cli -- [database-path] [command] [args...] +```bash +# Clone with submodules, because the shared core lives in src/lib +git clone --recurse-submodules +cd obsidian-livesync + +# If you already cloned without submodules, run this once instead +git submodule update --init --recursive + +# Install dependencies from the repository root +npm install + +# Build the CLI from its package directory +cd src/apps/cli +npm run build +``` + +If `src/lib` is missing, `npm run build` now stops early with a targeted message +instead of a low-level Vite `ENOENT` error. + +Run the CLI: + +```bash +# Run with npm script (from repository root) +npm run --silent cli -- [database-path] [command] [args...] # Run the built executable directly node src/apps/cli/dist/index.cjs [database-path] [command] [args...] ``` -### Docker - -A Docker image is provided for headless / server deployments. Build from the repository root: +### Docker + +A Docker image is provided for headless / server deployments. Build from the repository root: ```bash docker build -f src/apps/cli/Dockerfile -t livesync-cli . @@ -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 @@ -297,9 +311,11 @@ Options: --force, -f Overwrite existing file on init-settings --verbose, -v Enable verbose logging --debug, -d Enable debug logging (includes verbose) - --help, -h Show help message + --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed + --help, -h Show this help message Commands: + daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem init-settings [path] Create settings JSON from DEFAULT_SETTINGS sync Run one replication cycle and exit p2p-peers Show discovered peers as [peer] @@ -406,6 +422,86 @@ In other words, it performs the following actions: Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases). +##### daemon + +`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions: + +- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`). +- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB. + +In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage. + +The daemon exits cleanly on `SIGINT` or `SIGTERM`. + +```bash +# LiveSync mode (default — _changes feed, near-real-time) +livesync-cli /path/to/vault + +# Polling mode — poll every 60 seconds +livesync-cli /path/to/vault --interval 60 +``` + +### .livesync/ignore + +Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local). + +**Format:** + +- Lines beginning with `#` are comments. +- Blank lines are ignored. +- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root. +- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules. +- Negation patterns (lines starting with `!`) are not supported and will cause an error on load. + +**Example `.livesync/ignore`:** + +``` +# Ignore temporary files +*.tmp +*.swp + +# Ignore build output +build/ +dist/ + +# Merge patterns from .gitignore +import: .gitignore +``` + +Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync. + +Changes to this file require a daemon restart to take effect. + +### Systemd Installation + +The `deploy/` directory contains a systemd unit template and an install script. + +**Automated install (user service, recommended):** + +```bash +bash src/apps/cli/deploy/install.sh --vault /path/to/vault +``` + +**With polling interval:** + +```bash +bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60 +``` + +**System-wide install** (requires root / sudo for `/etc/systemd/system/`): + +```bash +bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault +``` + +The script: +1. Builds the CLI (`npm install` + `npm run build`). +2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system). +3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system). +4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`. + +**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory. + ### Planned options: - `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`). diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.ts index b90ad73..34d434e 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.ts @@ -39,12 +39,6 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { const pathStr = this.normalisePath(p); - - const cached = this.fileCache.get(pathStr); - if (cached) { - return cached; - } - return await this.refreshFile(pathStr); } @@ -104,14 +98,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { const stat = await fs.stat(this.resolvePath(p)); return { size: stat.size, - mtime: stat.mtimeMs, - ctime: stat.ctimeMs, + mtime: Math.floor(stat.mtimeMs), + ctime: Math.floor(stat.ctimeMs), type: stat.isDirectory() ? "folder" : "file", }; } catch { diff --git a/src/apps/cli/adapters/NodeVaultAdapter.ts b/src/apps/cli/adapters/NodeVaultAdapter.ts index 947ad01..e313f39 100644 --- a/src/apps/cli/adapters/NodeVaultAdapter.ts +++ b/src/apps/cli/adapters/NodeVaultAdapter.ts @@ -15,7 +15,12 @@ export class NodeVaultAdapter implements IVaultAdapter { } async read(file: NodeFile): Promise { - return await fs.readFile(this.resolvePath(file.path), "utf-8"); + const content = await fs.readFile(this.resolvePath(file.path), "utf-8"); + // Correct stale stat.size — chokidar stats may be from a poll before the final write. + // The downstream document integrity check compares stat.size to content length, so + // they must agree or other clients reject the file as corrupted. + file.stat.size = Buffer.byteLength(content, "utf-8"); + return content; } async cachedRead(file: NodeFile): Promise { @@ -25,6 +30,8 @@ export class NodeVaultAdapter implements IVaultAdapter { async readBinary(file: NodeFile): Promise { const buffer = await fs.readFile(this.resolvePath(file.path)); + // Same correction as read() — ensure stat.size matches actual byte length. + file.stat.size = buffer.length; return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; } @@ -66,8 +73,8 @@ export class NodeVaultAdapter implements IVaultAdapter { path: p as any, stat: { size: stat.size, - mtime: stat.mtimeMs, - ctime: stat.ctimeMs, + mtime: Math.floor(stat.mtimeMs), + ctime: Math.floor(stat.ctimeMs), type: "file", }, }; @@ -89,8 +96,8 @@ export class NodeVaultAdapter implements IVaultAdapter { path: p as any, stat: { size: stat.size, - mtime: stat.mtimeMs, - ctime: stat.ctimeMs, + mtime: Math.floor(stat.mtimeMs), + ctime: Math.floor(stat.ctimeMs), type: "file", }, }; diff --git a/src/apps/cli/commands/daemonCommand.unit.spec.ts b/src/apps/cli/commands/daemonCommand.unit.spec.ts new file mode 100644 index 0000000..2e2a341 --- /dev/null +++ b/src/apps/cli/commands/daemonCommand.unit.spec.ts @@ -0,0 +1,312 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { runCommand } from "./runCommand"; +import type { CLIOptions } from "./types"; + +// Mock performFullScan so daemon tests don't require a real CouchDB connection. +vi.mock("@lib/serviceFeatures/offlineScanner", () => ({ + performFullScan: vi.fn(async () => true), +})); + +// Mock UnresolvedErrorManager to avoid event-hub side effects. +vi.mock("@lib/services/base/UnresolvedErrorManager", () => ({ + UnresolvedErrorManager: class UnresolvedErrorManager { + showError() {} + clearError() {} + clearErrors() {} + }, +})); + +import * as offlineScanner from "@lib/serviceFeatures/offlineScanner"; + +function createCoreMock() { + return { + services: { + control: { + activated: Promise.resolve(), + applySettings: vi.fn(async () => {}), + }, + setting: { + applyPartial: vi.fn(async () => {}), + currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })), + }, + replication: { + replicate: vi.fn(async () => true), + }, + appLifecycle: { + onUnload: { + addHandler: vi.fn(), + }, + }, + }, + serviceModules: { + fileHandler: { + dbToStorage: vi.fn(async () => true), + storeFileToDB: vi.fn(async () => true), + }, + storageAccess: { + readFileAuto: vi.fn(async () => ""), + writeFileAuto: vi.fn(async () => {}), + }, + databaseFileAccess: { + fetch: vi.fn(async () => undefined), + }, + }, + } as any; +} + +function makeDaemonOptions(interval?: number): CLIOptions { + return { + command: "daemon", + commandArgs: [], + databasePath: "/tmp/vault", + verbose: false, + force: false, + interval, + }; +} + +const baseContext = { + vaultPath: "/tmp/vault", + settingsPath: "/tmp/vault/.livesync/settings.json", + originalSyncSettings: { + liveSync: true, + syncOnStart: false, + periodicReplication: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + }, +} as any; + +describe("daemon command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("calls performFullScan during startup", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + + await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1); + }); + + it("returns false when performFullScan fails", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false); + + const result = await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + expect(result).toBe(false); + }); + + it("polling mode: calls setTimeout when interval option is set", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + await runCommand(makeDaemonOptions(30), { ...baseContext, core }); + + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + // Interval should be in milliseconds (30s → 30000ms) + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000); + }); + + it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + + await runCommand(makeDaemonOptions(10), { ...baseContext, core }); + + expect(core.services.setting.applyPartial).toHaveBeenCalledWith( + expect.objectContaining({ suspendFileWatching: false }), + true + ); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + }); + + it("liveSync mode: calls applyPartial and applySettings", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + + await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + expect(core.services.setting.applyPartial).toHaveBeenCalledWith( + expect.objectContaining({ + ...baseContext.originalSyncSettings, + suspendFileWatching: false, + }), + true + ); + expect(core.services.control.applySettings).toHaveBeenCalledTimes(1); + }); + + it("liveSync mode: logs warning when both liveSync and syncOnStart are false", async () => { + const core = createCoreMock(); + core.services.setting.currentSettings = vi.fn(() => ({ + liveSync: false, + syncOnStart: false, + })); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + expect(result).toBe(true); + const warningCalls = consoleSpy.mock.calls.filter( + (args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled") + ); + expect(warningCalls.length).toBeGreaterThan(0); + }); + + it("liveSync mode: no warning when liveSync is true", async () => { + const core = createCoreMock(); + core.services.setting.currentSettings = vi.fn(() => ({ + liveSync: true, + syncOnStart: false, + })); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + const warningCalls = consoleSpy.mock.calls.filter( + (args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled") + ); + expect(warningCalls.length).toBe(0); + }); + + it("calls replicate before performFullScan", async () => { + const core = createCoreMock(); + const callOrder: string[] = []; + core.services.replication.replicate = vi.fn(async () => { + callOrder.push("replicate"); + return true; + }); + vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => { + callOrder.push("performFullScan"); + return true; + }); + + await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + expect(callOrder).toEqual(["replicate", "performFullScan"]); + }); + + it("returns false when initial replication fails", async () => { + const core = createCoreMock(); + core.services.replication.replicate = vi.fn(async () => false); + vi.mocked(offlineScanner.performFullScan).mockClear(); + + const result = await runCommand(makeDaemonOptions(), { ...baseContext, core }); + + expect(result).toBe(false); + // performFullScan should NOT have been called + expect(offlineScanner.performFullScan).not.toHaveBeenCalled(); + }); + + it("polling mode: registers onUnload handler that clears timeout", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + + await runCommand(makeDaemonOptions(10), { ...baseContext, core }); + + // onUnload handler should have been registered + expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1); + const handler = core.services.appLifecycle.onUnload.addHandler.mock.calls[0][0]; + + // Get the timeout ID that was created + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout"); + await handler(); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + }); + + it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + vi.spyOn(console, "error").mockImplementation(() => {}); + + // startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds. + let callCount = 0; + core.services.replication.replicate = vi.fn(async () => { + callCount++; + if (callCount === 1) return true; // initial startup replicate + if (callCount <= 7) throw new Error("network failure"); + return true; // recovery + }); + + const baseMs = 30 * 1000; + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + await runCommand(makeDaemonOptions(30), { ...baseContext, core }); + + // After runCommand returns the first setTimeout has been scheduled. + // setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs). + expect(setTimeoutSpy.mock.calls[0][1]).toBe(baseMs); + + // Advance through 6 failure polls. After each failure the next setTimeout + // should be scheduled with a larger (or capped) interval. + // formula: min(base * 2^n, 300000). base=30000ms. + // failure 1: 30000*2=60000, failure 2: 30000*4=120000, + // failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap + const expectedIntervals = [ + baseMs * 2, // after failure 1: 60000 + baseMs * 4, // after failure 2: 120000 + baseMs * 8, // after failure 3: 240000 + 300_000, // after failure 4 (would be 480000, capped) + 300_000, // after failure 5 (cap) + 300_000, // after failure 6 (cap) + ]; + + for (const expected of expectedIntervals) { + const prevCallCount = setTimeoutSpy.mock.calls.length; + await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number); + const newCallCount = setTimeoutSpy.mock.calls.length; + expect(newCallCount).toBeGreaterThan(prevCallCount); + expect(setTimeoutSpy.mock.calls[newCallCount - 1][1]).toBe(expected); + } + + // Now trigger the success poll — interval should halve each time toward base. + // After failure 6, consecutiveFailures=6, currentIntervalMs=300000. + // On success: consecutiveFailures=5, currentIntervalMs=150000. + const prevCallCount = setTimeoutSpy.mock.calls.length; + await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number); + const afterSuccessCallCount = setTimeoutSpy.mock.calls.length; + expect(afterSuccessCallCount).toBeGreaterThan(prevCallCount); + // The interval after one success should be halved (300000 / 2 = 150000). + expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000); + }); + + it("polling error handling: replicate rejection is caught and console.error is called", async () => { + const core = createCoreMock(); + vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // Make replicate succeed on the initial call (startup), then fail on the poll. + let callCount = 0; + core.services.replication.replicate = vi.fn(async () => { + callCount++; + if (callCount === 1) return true; // startup replicate + throw new Error("network failure"); + }); + + const intervalMs = 30 * 1000; + await runCommand(makeDaemonOptions(30), { ...baseContext, core }); + + // Advance time to trigger the first poll callback and flush its async work. + await vi.advanceTimersByTimeAsync(intervalMs); + + // No unhandled rejection — the error was caught internally. + const errorCalls = consoleSpy.mock.calls.filter( + (args) => typeof args[0] === "string" && args[0].includes("Poll error") + ); + expect(errorCalls.length).toBeGreaterThan(0); + }); +}); diff --git a/src/apps/cli/commands/p2p.ts b/src/apps/cli/commands/p2p.ts index f47b62e..e3297ee 100644 --- a/src/apps/cli/commands/p2p.ts +++ b/src/apps/cli/commands/p2p.ts @@ -32,10 +32,15 @@ function validateP2PSettings(core: LiveSyncBaseCore) { settings.P2P_IsHeadless = true; } -function createReplicator(core: LiveSyncBaseCore): LiveSyncTrysteroReplicator { +async function createReplicator(core: LiveSyncBaseCore): Promise { validateP2PSettings(core); - const replicator = new LiveSyncTrysteroReplicator({ services: core.services }); - addP2PEventHandlers(replicator); + const replicator = await core.services.replicator.getNewReplicator(); + if (!replicator) { + throw new Error("Failed to create replicator instance. Ensure P2P is enabled in settings."); + } + if (!(replicator instanceof LiveSyncTrysteroReplicator)) { + throw new Error("Unexpected replicator type. Expected LiveSyncTrysteroReplicator."); + } return replicator; } @@ -49,7 +54,7 @@ export async function collectPeers( core: LiveSyncBaseCore, timeoutSec: number ): Promise { - const replicator = createReplicator(core); + const replicator = await createReplicator(core); await replicator.open(); try { await delay(timeoutSec * 1000); @@ -79,7 +84,7 @@ export async function syncWithPeer( peerToken: string, timeoutSec: number ): Promise { - const replicator = createReplicator(core); + const replicator = await createReplicator(core); await replicator.open(); try { const timeoutMs = timeoutSec * 1000; @@ -115,7 +120,7 @@ export async function syncWithPeer( } export async function openP2PHost(core: LiveSyncBaseCore): Promise { - const replicator = createReplicator(core); + const replicator = await createReplicator(core); await replicator.open(); return replicator; } diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index e188c23..cbcb955 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -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,11 +12,115 @@ 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 { const { databasePath, core, settingsPath } = context; await core.services.control.activated; if (options.command === "daemon") { + const log = (msg: unknown) => console.error(`[Daemon] ${msg}`); + + // Skip the config mismatch dialog — the daemon cannot resolve it interactively + // and the default "Dismiss" action would block replication. The daemon should + // accept whatever configuration the remote has. + await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true); + + // 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with. + log("Replicating from CouchDB..."); + const replResult = await core.services.replication.replicate(true); + if (!replResult) { + console.error("[Daemon] Initial CouchDB replication failed, cannot continue"); + return false; + } + log("CouchDB replication complete"); + + // 2. Mirror scan to reconcile PouchDB ↔ local filesystem. + const errorManager = new UnresolvedErrorManager(core.services.appLifecycle); + log("Running mirror scan..."); + const scanOk = await performFullScan(core as any, log, errorManager, false, true); + if (!scanOk) { + console.error("[Daemon] Mirror scan failed, cannot continue"); + return false; + } + log("Mirror scan complete"); + + // 3. Re-enable sync. + const restoreSyncSettings = async () => { + await core.services.setting.applyPartial( + { + ...context.originalSyncSettings, + suspendFileWatching: false, + }, + true + ); + // applySettings fires the full lifecycle: onSuspending → onResumed. + // ModuleReplicatorCouchDB starts continuous replication on onResumed + // via fireAndForget. + await core.services.control.applySettings(); + // Lifecycle events (onSuspending) may re-enable suspension flags. + // Clear them explicitly after the lifecycle completes. applyPartial + // with true is a direct store write — it does not re-trigger lifecycle. + await core.services.setting.applyPartial( + { + suspendFileWatching: false, + suspendParseReplicationResult: false, + }, + true + ); + }; + if (options.interval) { + log(`Polling mode: syncing every ${options.interval}s`); + await restoreSyncSettings(); + const baseIntervalMs = options.interval * 1000; + let currentIntervalMs = baseIntervalMs; + let consecutiveFailures = 0; + const maxIntervalMs = 5 * 60 * 1000; // 5 minutes cap + + const poll = async () => { + try { + await core.services.replication.replicate(true); + if (consecutiveFailures > 0) { + consecutiveFailures--; + currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs); + log(`Replication recovered`); + } + } catch (err) { + consecutiveFailures++; + currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs); + console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err); + if (consecutiveFailures >= 5) { + console.error( + `[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s` + ); + } + } + pollTimer = setTimeout(poll, currentIntervalMs); + }; + let pollTimer: ReturnType = setTimeout(poll, currentIntervalMs); + core.services.appLifecycle.onUnload.addHandler(async () => { + clearTimeout(pollTimer); + return true; + }); + } else { + log("LiveSync mode: restoring sync settings and starting _changes feed"); + await restoreSyncSettings(); + // The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which + // starts continuous replication via fireAndForget(openReplication). Don't call + // openReplication directly — it races with the handler and causes dedup/termination. + log("LiveSync active"); + const currentSettings = core.services.setting.currentSettings(); + if (!currentSettings.liveSync && !currentSettings.syncOnStart) { + console.error( + "[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " + + "No sync will occur. Set liveSync=true in your settings file for continuous sync, " + + "or use --interval for polling mode." + ); + } + } + return true; } @@ -83,8 +189,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`); await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), { - mtime: sourceStat.mtimeMs, - ctime: sourceStat.ctimeMs, + mtime: Math.floor(sourceStat.mtimeMs), + ctime: Math.floor(sourceStat.ctimeMs), }); const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix; const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true); @@ -369,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: "); + } + 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: "); + } + 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: "); + } + 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: "); + } + 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: "); + } + 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}`); } diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 85a91b8..204394a 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -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 { 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: \t\t + 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); + }); }); diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index f63f751..2f5337f 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -1,5 +1,6 @@ import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore"; import { ServiceContext } from "@lib/services/base/ServiceBase"; +import type { ObsidianLiveSyncSettings } from "@lib/common/types"; export type CLICommand = | "daemon" @@ -19,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 { @@ -29,15 +36,27 @@ export interface CLIOptions { force?: boolean; command: CLICommand; commandArgs: string[]; + interval?: number; } export interface CLICommandContext { databasePath: string; core: LiveSyncBaseCore; settingsPath: string; + originalSyncSettings: Pick< + ObsidianLiveSyncSettings, + | "liveSync" + | "syncOnStart" + | "periodicReplication" + | "syncOnSave" + | "syncOnEditorSave" + | "syncOnFileOpen" + | "syncAfterMerge" + >; } export const VALID_COMMANDS = new Set([ + "daemon", "sync", "p2p-peers", "p2p-sync", @@ -54,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); diff --git a/src/apps/cli/deploy/install.sh b/src/apps/cli/deploy/install.sh new file mode 100755 index 0000000..d0d3a2e --- /dev/null +++ b/src/apps/cli/deploy/install.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# install.sh — install livesync-cli as a systemd service +# +# Usage: +# install.sh [--user] [--system] [--vault ] [--interval ] +# +# Defaults: user install, prompts for vault path if not supplied. +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +CLI_DIR="$REPO_ROOT/src/apps/cli" +SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service" + +# ── Argument parsing ──────────────────────────────────────────────────────── +INSTALL_MODE="user" +VAULT_PATH="" +INTERVAL="" +FORCE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --user) + INSTALL_MODE="user" + shift + ;; + --system) + INSTALL_MODE="system" + shift + ;; + --vault) + if [[ -z "${2:-}" ]]; then + echo "Error: --vault requires a path argument" >&2 + exit 1 + fi + VAULT_PATH="$2" + shift 2 + ;; + --interval) + if [[ -z "${2:-}" ]]; then + echo "Error: --interval requires a numeric argument" >&2 + exit 1 + fi + INTERVAL="$2" + if ! [[ "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: --interval requires a positive integer, got '$INTERVAL'" >&2 + exit 1 + fi + shift 2 + ;; + --force|-f) + FORCE=1 + shift + ;; + --help|-h) + cat <] [--interval ] [--force] + + --user Install as a user systemd service (default, ~/.config/systemd/user/) + --system Install as a system systemd service (/etc/systemd/system/) + --vault Path to the vault directory (prompted if omitted) + --interval Poll CouchDB every N seconds instead of using the _changes feed + --force Overwrite existing service unit without prompting +EOF + exit 0 + ;; + *) + echo "Error: Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +# ── Vault path ────────────────────────────────────────────────────────────── +if [[ -z "$VAULT_PATH" ]]; then + if [ ! -t 0 ]; then + echo "Error: --vault is required in non-interactive mode" >&2 + exit 1 + fi + printf 'Vault path: ' + read -r VAULT_PATH +fi + +_orig_vault="$VAULT_PATH" +if ! VAULT_PATH="$(cd -- "$VAULT_PATH" 2>/dev/null && pwd)"; then + echo "Error: vault directory does not exist: $_orig_vault" >&2 + exit 1 +fi + +echo "[INFO] Vault: $VAULT_PATH" +echo "[INFO] Install mode: $INSTALL_MODE" + +# ── Build ──────────────────────────────────────────────────────────────────── +echo "[INFO] Building CLI from $REPO_ROOT..." +(cd "$REPO_ROOT" && npm install --silent) +(cd "$CLI_DIR" && npm run build) + +BUILT_CJS="$CLI_DIR/dist/index.cjs" +if [[ ! -f "$BUILT_CJS" ]]; then + echo "Error: build output not found: $BUILT_CJS" >&2 + exit 1 +fi + +# ── Install binary ─────────────────────────────────────────────────────────── +if [[ "$INSTALL_MODE" == "user" ]]; then + BIN_DIR="$HOME/.local/bin" + UNIT_DIR="$HOME/.config/systemd/user" + SYSTEMCTL_FLAGS="--user" +else + BIN_DIR="/usr/local/bin" + UNIT_DIR="/etc/systemd/system" + SYSTEMCTL_FLAGS="" +fi + +mkdir -p "$BIN_DIR" + +LIVESYNC_BIN="$BIN_DIR/livesync-cli" +LIVESYNC_JS="$BIN_DIR/livesync-cli.js" + +# Copy the CJS bundle so the wrapper is self-contained and independent of the +# build directory location. +cp "$BUILT_CJS" "$LIVESYNC_JS" + +# Write a bash wrapper that invokes node on the installed bundle. +cat > "$LIVESYNC_BIN" <&2 + exit 1 + fi + printf 'Service unit already exists at %s. Overwrite? [y/N]: ' "$UNIT_PATH" + read -r CONFIRM + case "$CONFIRM" in + [yY]|[yY][eE][sS]) : ;; + *) + echo "[INFO] Aborted. Existing unit left in place." + exit 0 + ;; + esac +fi + +# In awk gsub(), '&' in the replacement means "matched text"; escape any literal '&' +# in path variables before passing them as awk replacement strings. +AWK_BIN="${LIVESYNC_BIN//&/\\&}" +AWK_VAULT="${VAULT_PATH//&/\\&}" +awk -v bin="$AWK_BIN" -v vault="$AWK_VAULT" -v exec_start="ExecStart=$EXEC_START" \ + '/^ExecStart=/ { print exec_start; next } {gsub("LIVESYNC_BIN", bin); gsub("LIVESYNC_VAULT_PATH", vault); print}' \ + "$SERVICE_TEMPLATE" > "$UNIT_PATH" + +echo "[INFO] Installed unit: $UNIT_PATH" + +# ── Enable service ─────────────────────────────────────────────────────────── +if ! command -v systemctl >/dev/null 2>&1; then + echo "[WARN] systemctl not found — skipping service activation" + echo "[INFO] To enable manually, copy $UNIT_PATH to the correct systemd directory and run:" + echo " systemctl $SYSTEMCTL_FLAGS daemon-reload" + echo " systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli" + exit 0 +fi + +# shellcheck disable=SC2086 +systemctl $SYSTEMCTL_FLAGS daemon-reload +# shellcheck disable=SC2086 +systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli + +echo "" +echo "[Done] livesync-cli service installed and started." +echo "" +# shellcheck disable=SC2086 +systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true diff --git a/src/apps/cli/deploy/livesync-cli.service b/src/apps/cli/deploy/livesync-cli.service new file mode 100644 index 0000000..b76e786 --- /dev/null +++ b/src/apps/cli/deploy/livesync-cli.service @@ -0,0 +1,17 @@ +[Unit] +Description=Self-hosted LiveSync CLI Daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH +Restart=on-failure +RestartSec=10 +TimeoutStartSec=300 +StandardOutput=journal +StandardError=journal +LimitNOFILE=65536 + +[Install] +WantedBy=default.target diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 97483d5..1bd1ea5 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -8,7 +8,6 @@ import * as path from "path"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage"; import { LiveSyncBaseCore } from "../../LiveSyncBaseCore"; -import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types"; import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; @@ -26,6 +25,8 @@ import { VALID_COMMANDS } from "./commands/types"; import type { CLICommand, CLIOptions } from "./commands/types"; import { getPathFromUXFileInfo } from "@lib/common/typeUtils"; import { stripAllPrefixes } from "@lib/string_and_binary/path"; +import { IgnoreRules } from "./serviceModules/IgnoreRules"; +import { useP2PReplicatorFeature } from "@/lib/src/replication/trystero/useP2PReplicatorFeature"; const SETTINGS_FILE = ".livesync/settings.json"; ensureGlobalNodeLocalStorage(); @@ -43,7 +44,8 @@ Arguments: database-path Path to the local database directory Commands: - sync Run one replication cycle and exit + daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem + sync Run one replication cycle and exit p2p-peers Show discovered peers as [peer]\t\t p2p-sync Sync with the specified peer-id or peer-name @@ -60,24 +62,46 @@ Commands: rm Mark a file as deleted in local database resolve Resolve conflicts by keeping and deleting others mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path) + remote-add + Add a remote configuration from a connection string + remote-rm Remove a remote configuration by ID + remote-ls List stored remote configurations + remote-export + Export a remote connection string by ID + remote-set + Replace a stored remote connection string by ID + remote-activate + Activate a stored remote configuration by ID + +Options: + --interval , -i (daemon only) Poll CouchDB every N seconds instead of using the _changes feed + Examples: - livesync-cli ./my-database sync - livesync-cli ./my-database p2p-peers 5 - livesync-cli ./my-database p2p-sync my-peer-name 15 - livesync-cli ./my-database p2p-host - livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md - livesync-cli ./my-database pull folder/note.md ./exports/note.md - livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef - livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..." - echo "Hello" | livesync-cli ./my-database put notes/hello.md - livesync-cli ./my-database cat notes/hello.md - livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef - livesync-cli ./my-database ls notes/ - 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 init-settings ./data.json - livesync-cli ./my-database --verbose + livesync-cli ./my-database Run daemon (LiveSync mode) + livesync-cli ./my-database --interval 30 Run daemon (polling every 30s) + livesync-cli ./my-database sync + livesync-cli ./my-database p2p-peers 5 + livesync-cli ./my-database p2p-sync my-peer-name 15 + livesync-cli ./my-database p2p-host + livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md + livesync-cli ./my-database pull folder/note.md ./exports/note.md + livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef + livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..." + echo "Hello" | livesync-cli ./my-database put notes/hello.md + livesync-cli ./my-database cat notes/hello.md + livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef + livesync-cli ./my-database ls notes/ + 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 `); } @@ -94,6 +118,7 @@ export function parseArgs(): CLIOptions { let verbose = false; let debug = false; let force = false; + let interval: number | undefined; let command: CLICommand = "daemon"; const commandArgs: string[] = []; @@ -110,6 +135,21 @@ export function parseArgs(): CLIOptions { settingsPath = args[i]; break; } + case "--interval": + case "-i": { + i++; + if (!args[i]) { + console.error(`Error: Missing value for ${token}`); + process.exit(1); + } + const n = parseInt(args[i], 10); + if (!Number.isInteger(n) || n <= 0) { + console.error(`Error: --interval requires a positive integer, got '${args[i]}'`); + process.exit(1); + } + interval = n; + break; + } case "--debug": case "-d": // debugging automatically enables verbose logging, as it is intended for debugging issues. @@ -164,6 +204,7 @@ export function parseArgs(): CLIOptions { force, command, commandArgs, + interval, }; } @@ -197,10 +238,16 @@ async function createDefaultSettingsFile(options: CLIOptions) { export async function main() { const options = parseArgs(); + if (options.interval && options.command !== "daemon") { + console.error(`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`); + } const avoidStdoutNoise = 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" || @@ -248,6 +295,17 @@ export async function main() { infoLog(`Settings: ${settingsPath}`); infoLog(""); + // For daemon and mirror mode, load ignore rules before the core is constructed so that + // chokidar's ignored option is populated when beginWatch() fires during onLoad(). + const watchEnabled = options.command === "daemon"; + const vaultPath = + options.command === "mirror" && options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : databasePath; + let ignoreRules: IgnoreRules | undefined; + if (options.command === "daemon" || options.command === "mirror") { + ignoreRules = new IgnoreRules(vaultPath); + await ignoreRules.load(); + } + // Create service context and hub const context = new NodeServiceContext(databasePath); const serviceHubInstance = new NodeServiceHub(databasePath, context); @@ -278,11 +336,14 @@ export async function main() { } console.error(`${prefix} ${message}`); }); - // Prevent replication result to be processed automatically. - serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => { - console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`); - return await Promise.resolve(true); - }, -100); + // Prevent replication result from being processed automatically in non-daemon commands. + // In daemon mode the default handler must run so changes are applied to the filesystem. + if (options.command !== "daemon") { + serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => { + console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`); + return await Promise.resolve(true); + }, -100); + } // Setup settings handlers const settingService = serviceHubInstance.setting; @@ -324,18 +385,13 @@ export async function main() { const core = new LiveSyncBaseCore( serviceHubInstance, (core: LiveSyncBaseCore, serviceHub: InjectableServiceHub) => { - const mirrorVaultPath = - options.command === "mirror" && options.commandArgs[0] - ? path.resolve(options.commandArgs[0]) - : databasePath; - return initialiseServiceModulesCLI(mirrorVaultPath, core, serviceHub); + return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled); }, - (core) => [ - // No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts - // new ModuleReplicatorP2P(core), - ], + (core) => [], () => [], // No add-ons (core) => { + // Register P2P replicator feature. + const _replicator = useP2PReplicatorFeature(core); // Add target filter to prevent internal files are handled core.services.vault.isTargetFile.addHandler(async (target) => { const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target)); @@ -344,8 +400,25 @@ export async function main() { if (parts.some((part) => part.startsWith("."))) { return await Promise.resolve(false); } + // PouchDB LevelDB database directory lives in the vault directory. + if (parts[0]?.endsWith("-livesync-v2")) { + return await Promise.resolve(false); + } return await Promise.resolve(true); }, -1 /* highest priority */); + + // Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check). + if (ignoreRules) { + const rules = ignoreRules; + core.services.vault.isTargetFile.addHandler(async (target) => { + const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target)); + if (rules.shouldIgnore(targetPath)) { + return false; + } + // undefined = pass through to next handler in chain + return undefined; + }, 0); + } } ); @@ -366,6 +439,25 @@ export async function main() { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); + // Save the settings file before any lifecycle events can mutate and persist them. + // suspendAllSync and other lifecycle hooks clobber sync settings in memory, and + // various code paths persist the clobbered state to disk. We restore on shutdown. + const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!); + + // Restore settings file on any exit to undo lifecycle mutations. + // Write to a temp path first so a crash mid-write doesn't leave a truncated file. + process.on("exit", () => { + if (settingsBackup) { + const tmpPath = settingsPath + ".tmp"; + try { + require("fs").writeFileSync(tmpPath, settingsBackup, "utf-8"); + require("fs").renameSync(tmpPath, settingsPath); + } catch (err) { + console.error("[Settings] Failed to restore settings on exit:", err); + } + } + }); + // Start the core try { infoLog(`[Starting] Initializing LiveSync...`); @@ -375,6 +467,18 @@ export async function main() { console.error(`[Error] Failed to initialize LiveSync`); process.exit(1); } + // Capture sync settings before suspendAllSync() clobbers them. + // Used by daemon mode to restore the correct sync behaviour after the mirror scan. + const settingsBeforeSuspend = core.services.setting.currentSettings(); + const originalSyncSettings = { + liveSync: settingsBeforeSuspend.liveSync, + syncOnStart: settingsBeforeSuspend.syncOnStart, + periodicReplication: settingsBeforeSuspend.periodicReplication, + syncOnSave: settingsBeforeSuspend.syncOnSave, + syncOnEditorSave: settingsBeforeSuspend.syncOnEditorSave, + syncOnFileOpen: settingsBeforeSuspend.syncOnFileOpen, + syncAfterMerge: settingsBeforeSuspend.syncAfterMerge, + }; await core.services.setting.suspendAllSync(); await core.services.control.onReady(); @@ -400,7 +504,7 @@ export async function main() { infoLog(""); } - const result = await runCommand(options, { databasePath, core, settingsPath }); + const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings }); if (!result) { console.error(`[Error] Command '${options.command}' failed`); process.exitCode = 1; @@ -408,7 +512,7 @@ export async function main() { infoLog(`[Done] Command '${options.command}' completed`); } - if (options.command === "daemon") { + if (options.command === "daemon" && result) { // Keep the process running await new Promise(() => {}); } else { diff --git a/src/apps/cli/main.unit.spec.ts b/src/apps/cli/main.unit.spec.ts index 4c35ae9..63c1633 100644 --- a/src/apps/cli/main.unit.spec.ts +++ b/src/apps/cli/main.unit.spec.ts @@ -85,4 +85,117 @@ describe("CLI parseArgs", () => { expect(parsed.command).toBe("p2p-host"); 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(); + expect(parsed.command).toBe("daemon"); + expect(parsed.interval).toBe(30); + }); + + it("parses -i shorthand for --interval", () => { + process.argv = ["node", "livesync-cli", "./vault", "-i", "10"]; + const parsed = parseArgs(); + expect(parsed.interval).toBe(10); + }); + + it("exits 1 when --interval has no value", () => { + process.argv = ["node", "livesync-cli", "./vault", "--interval"]; + const exitMock = mockProcessExit(); + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => parseArgs()).toThrowError("__EXIT__:1"); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it("exits 1 when --interval is not a positive integer", () => { + process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"]; + const exitMock = mockProcessExit(); + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => parseArgs()).toThrowError("__EXIT__:1"); + expect(exitMock).toHaveBeenCalledWith(1); + }); + + it("exits 1 when --interval is negative", () => { + process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"]; + const exitMock = mockProcessExit(); + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => parseArgs()).toThrowError("__EXIT__:1"); + }); + + it("exits 1 when --interval is not numeric", () => { + process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"]; + const exitMock = mockProcessExit(); + vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => parseArgs()).toThrowError("__EXIT__:1"); + }); + + it("parses explicit daemon command", () => { + process.argv = ["node", "livesync-cli", "./vault", "daemon"]; + const parsed = parseArgs(); + expect(parsed.command).toBe("daemon"); + expect(parsed.databasePath).toBe("./vault"); + }); + + it("defaults to daemon when no command specified", () => { + process.argv = ["node", "livesync-cli", "./vault"]; + const parsed = parseArgs(); + expect(parsed.command).toBe("daemon"); + }); + + it("parses explicit daemon command with --interval", () => { + process.argv = ["node", "livesync-cli", "./vault", "daemon", "--interval", "30"]; + const parsed = parseArgs(); + expect(parsed.command).toBe("daemon"); + expect(parsed.interval).toBe(30); + }); }); diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index 1334b6a..c2f11e1 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -11,8 +11,11 @@ import type { } from "@lib/managers/adapters"; import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager"; import type { NodeFile, NodeFolder } from "../adapters/NodeTypes"; +import type { Stats } from "fs"; import * as fs from "fs/promises"; import * as path from "path"; +import { watch as chokidarWatch, type FSWatcher } from "chokidar"; +import type { IgnoreRules } from "../serviceModules/IgnoreRules"; /** * CLI-specific type guard adapter @@ -56,22 +59,11 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter { } /** - * CLI-specific status adapter (console logging) + * CLI-specific status adapter (no-op — daemon uses journald for status) */ class CLIStatusAdapter implements IStorageEventStatusAdapter { - private lastUpdate = 0; - private updateInterval = 5000; // Update every 5 seconds - - updateStatus(status: { batched: number; processing: number; totalQueued: number }): void { - const now = Date.now(); - if (now - this.lastUpdate > this.updateInterval) { - if (status.totalQueued > 0 || status.processing > 0) { - // console.log( - // `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}` - // ); - } - this.lastUpdate = now; - } + updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void { + // intentional no-op } } @@ -100,15 +92,101 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter { } /** - * CLI-specific watch adapter (optional file watching with chokidar) + * CLI-specific watch adapter using chokidar for real-time filesystem monitoring. */ class CLIWatchAdapter implements IStorageEventWatchAdapter { - constructor(private basePath: string) {} + private _watcher: FSWatcher | undefined; + + constructor( + private basePath: string, + private ignoreRules?: IgnoreRules, + private watchEnabled: boolean = false + ) {} + + private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile { + return { + path: path.relative(this.basePath, filePath) as FilePath, + stat: { + ctime: stats?.ctimeMs ?? Date.now(), + mtime: stats?.mtimeMs ?? Date.now(), + size: stats?.size ?? 0, + type: "file", + }, + }; + } + + private _toNodeFolder(dirPath: string): NodeFolder { + return { + path: path.relative(this.basePath, dirPath) as FilePath, + isFolder: true, + }; + } async beginWatch(handlers: IStorageEventWatchHandlers): Promise { - // File watching is not activated in the CLI. - // Because the CLI is designed for push/pull operations, not real-time sync. - // console.error("[CLIWatchAdapter] File watching is not enabled in CLI version"); + if (!this.watchEnabled) return; + const baseIgnored: Array boolean)> = [ + /(^|[/\\])\./, + /(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/, + ]; + // Bind rules to a local const before the closure — chokidar v4 requires a + // MatchFunction, not glob strings, for custom patterns. + const rules = this.ignoreRules; + const ignored = rules + ? [...baseIgnored, (p: string) => rules.shouldIgnore(path.relative(this.basePath, p))] + : baseIgnored; + + const watcher = chokidarWatch(this.basePath, { + ignored, + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }); + + watcher.on("add", (filePath, stats) => { + const nodeFile = this._toNodeFile(filePath, stats); + handlers.onCreate(nodeFile); + }); + + watcher.on("change", (filePath, stats) => { + const nodeFile = this._toNodeFile(filePath, stats); + handlers.onChange(nodeFile); + }); + + watcher.on("unlink", (filePath) => { + const nodeFile = this._toNodeFile(filePath, undefined); + handlers.onDelete(nodeFile); + }); + + watcher.on("addDir", (dirPath) => { + const nodeFolder = this._toNodeFolder(dirPath); + handlers.onCreate(nodeFolder); + }); + + watcher.on("unlinkDir", (dirPath) => { + const nodeFolder = this._toNodeFolder(dirPath); + handlers.onDelete(nodeFolder); + }); + + watcher.on("error", (err) => { + console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err); + console.error("[CLIWatchAdapter] Exiting for systemd restart."); + void watcher.close(); + this._watcher = undefined; + // Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages. + process.exit(1); + }); + + await new Promise((resolve) => watcher.once("ready", resolve)); + this._watcher = watcher; + } + + close(): Promise { + if (this._watcher) { + return this._watcher.close(); + } return Promise.resolve(); } } @@ -123,11 +201,15 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte readonly status: CLIStatusAdapter; readonly converter: CLIConverterAdapter; - constructor(basePath: string) { + constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) { this.typeGuard = new CLITypeGuardAdapter(); this.persistence = new CLIPersistenceAdapter(basePath); - this.watch = new CLIWatchAdapter(basePath); + this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled); this.status = new CLIStatusAdapter(); this.converter = new CLIConverterAdapter(); } + + close(): Promise { + return this.watch.close(); + } } diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts new file mode 100644 index 0000000..5af69a9 --- /dev/null +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { IStorageEventWatchHandlers } from "@lib/managers/adapters"; +import type { NodeFile } from "../adapters/NodeTypes"; + +// ── chokidar mock ────────────────────────────────────────────────────────────── +// Must be hoisted before imports that pull in chokidar. + +const mockWatcher = { + on: vi.fn().mockReturnThis(), + once: vi.fn((event: string, cb: () => void) => { + if (event === "ready") cb(); + return mockWatcher; + }), + close: vi.fn(() => Promise.resolve()), +}; + +vi.mock("chokidar", () => ({ + watch: vi.fn(() => mockWatcher), +})); + +import * as chokidar from "chokidar"; +import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter"; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function makeHandlers(): IStorageEventWatchHandlers { + return { + onCreate: vi.fn(), + onChange: vi.fn(), + onDelete: vi.fn(), + onRename: vi.fn(), + } as any; +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +describe("CLIStorageEventManagerAdapter", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Restore the default once() behaviour (ready fires synchronously). + mockWatcher.once.mockImplementation((event: string, cb: () => void) => { + if (event === "ready") cb(); + return mockWatcher; + }); + }); + + it("beginWatch is no-op when watchEnabled=false", async () => { + const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false); + const handlers = makeHandlers(); + + await adapter.watch.beginWatch(handlers); + + expect(chokidar.watch).not.toHaveBeenCalled(); + }); + + it("beginWatch calls chokidar.watch when watchEnabled=true", async () => { + const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true); + const handlers = makeHandlers(); + + await adapter.watch.beginWatch(handlers); + + expect(chokidar.watch).toHaveBeenCalledTimes(1); + expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true })); + }); + + it("add event produces NodeFile with correct relative path via onCreate", async () => { + const basePath = "/vault/base"; + const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true); + const handlers = makeHandlers(); + + await adapter.watch.beginWatch(handlers); + + // Find the callback registered for the "add" event. + const addCall = mockWatcher.on.mock.calls.find(([event]) => event === "add"); + expect(addCall).toBeDefined(); + const addCallback = addCall![1] as (filePath: string, stats: any) => void; + + const fakeStats = { ctimeMs: 1000, mtimeMs: 2000, size: 42 }; + addCallback(`${basePath}/subdir/note.md`, fakeStats); + + expect(handlers.onCreate).toHaveBeenCalledTimes(1); + const created = (handlers.onCreate as ReturnType).mock.calls[0][0] as NodeFile; + expect(created.path).toBe("subdir/note.md"); + expect(created.stat?.size).toBe(42); + }); + + it("close() calls watcher.close()", async () => { + const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true); + const handlers = makeHandlers(); + + await adapter.watch.beginWatch(handlers); + await adapter.close(); + + expect(mockWatcher.close).toHaveBeenCalledTimes(1); + }); + + it("close() is safe when no watcher was started", async () => { + const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false); + + // Should not throw. + await expect(adapter.close()).resolves.toBeUndefined(); + expect(mockWatcher.close).not.toHaveBeenCalled(); + }); + + it("error event triggers process.exit(1)", async () => { + const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true); + const handlers = makeHandlers(); + + await adapter.watch.beginWatch(handlers); + + const processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any); + + const errorCall = mockWatcher.on.mock.calls.find(([event]) => event === "error"); + expect(errorCall).toBeDefined(); + const errorCallback = errorCall![1] as (err: Error) => void; + + errorCallback(new Error("disk failure")); + + expect(processExitSpy).toHaveBeenCalledWith(1); + + processExitSpy.mockRestore(); + }); +}); diff --git a/src/apps/cli/managers/StorageEventManagerCLI.ts b/src/apps/cli/managers/StorageEventManagerCLI.ts index d1f2504..7838ef3 100644 --- a/src/apps/cli/managers/StorageEventManagerCLI.ts +++ b/src/apps/cli/managers/StorageEventManagerCLI.ts @@ -2,6 +2,7 @@ import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } fro import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter"; import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore"; import type { ServiceContext } from "@lib/services/base/ServiceBase"; +import type { IgnoreRules } from "../serviceModules/IgnoreRules"; // import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService"; export class StorageEventManagerCLI extends StorageEventManagerBase { @@ -10,9 +11,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase, - dependencies: StorageEventManagerBaseDependencies + dependencies: StorageEventManagerBaseDependencies, + ignoreRules?: IgnoreRules, + watchEnabled?: boolean ) { - const adapter = new CLIStorageEventManagerAdapter(basePath); + const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled); super(adapter, dependencies); this.core = core; } @@ -25,4 +28,11 @@ export class StorageEventManagerCLI extends StorageEventManagerBase { + return this.adapter.close(); + } } diff --git a/src/apps/cli/runtime-package.json b/src/apps/cli/runtime-package.json index 5791992..305d966 100644 --- a/src/apps/cli/runtime-package.json +++ b/src/apps/cli/runtime-package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image", "dependencies": { + "chokidar": "^4.0.0", "commander": "^14.0.3", "werift": "^0.22.9", "pouchdb-adapter-http": "^9.0.0", diff --git a/src/apps/cli/serviceModules/CLIServiceModules.ts b/src/apps/cli/serviceModules/CLIServiceModules.ts index 8cf0f40..50d185a 100644 --- a/src/apps/cli/serviceModules/CLIServiceModules.ts +++ b/src/apps/cli/serviceModules/CLIServiceModules.ts @@ -9,6 +9,7 @@ import { ServiceFileAccessCLI } from "./ServiceFileAccessImpl"; import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess"; import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI"; import type { ServiceModules } from "@lib/interfaces/ServiceModule"; +import type { IgnoreRules } from "./IgnoreRules"; /** * Initialize service modules for CLI version @@ -22,7 +23,9 @@ import type { ServiceModules } from "@lib/interfaces/ServiceModule"; export function initialiseServiceModulesCLI( basePath: string, core: LiveSyncBaseCore, - services: InjectableServiceHub + services: InjectableServiceHub, + ignoreRules?: IgnoreRules, + watchEnabled: boolean = false ): ServiceModules { const storageAccessManager = new StorageAccessManager(); @@ -36,12 +39,24 @@ export function initialiseServiceModulesCLI( }); // CLI-specific storage event manager - const storageEventManager = new StorageEventManagerCLI(basePath, core, { - fileProcessing: services.fileProcessing, - setting: services.setting, - vaultService: services.vault, - storageAccessManager: storageAccessManager, - APIService: services.API, + const storageEventManager = new StorageEventManagerCLI( + basePath, + core, + { + fileProcessing: services.fileProcessing, + setting: services.setting, + vaultService: services.vault, + storageAccessManager: storageAccessManager, + APIService: services.API, + }, + ignoreRules, + watchEnabled + ); + + // Close the file watcher during graceful shutdown so the process can exit cleanly. + services.appLifecycle.onUnload.addHandler(async () => { + await storageEventManager.close(); + return true; }); // Storage access using CLI file system adapter diff --git a/src/apps/cli/serviceModules/IgnoreRules.ts b/src/apps/cli/serviceModules/IgnoreRules.ts new file mode 100644 index 0000000..0b7fc41 --- /dev/null +++ b/src/apps/cli/serviceModules/IgnoreRules.ts @@ -0,0 +1,131 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +import { minimatch } from "minimatch"; + +/** + * Loads and evaluates ignore rules from `.livesync/ignore` inside the vault. + * + * File format: + * - Lines starting with `#` are comments. + * - Blank lines are ignored. + * - `import: .gitignore` (exactly) — merges patterns from the vault's `.gitignore`. + * - All other lines are minimatch glob patterns relative to the vault root. + * + * Negation patterns (lines starting with `!`) are not supported. Loading a + * ruleset containing them throws an error — use separate include/exclude files + * instead. + * + * Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped. + */ +export class IgnoreRules { + private patterns: string[] = []; + + constructor(private vaultPath: string) {} + + /** + * Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the + * pattern list. Safe to call multiple times — each call replaces the + * previous state. Does not throw if files are absent. + * + * @throws if any pattern line begins with `!` (negation is unsupported). + */ + async load(): Promise { + this.patterns = []; + const ignorePath = path.join(this.vaultPath, ".livesync", "ignore"); + let rawLines: string[]; + try { + const content = await fs.readFile(ignorePath, "utf-8"); + rawLines = content.split(/\r?\n/); + } catch { + // File absent or unreadable — treat as empty ruleset. + return; + } + + for (const line of rawLines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + // NOTE: Only the exact string "import: .gitignore" is recognised. + // Any future generalisation of this directive must validate that + // the resolved path stays within the vault directory. + if (trimmed === "import: .gitignore") { + await this._importGitignore(); + continue; + } + if (trimmed.startsWith("import:")) { + console.error( + `[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported` + ); + continue; + } + this._addPattern(trimmed); + } + if (this.patterns.length > 0) { + console.error(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`); + } + } + + // Normalises a single gitignore-style pattern: + // - Patterns ending with `/` (directory patterns like `build/`) are + // converted to `build/**` so they match all files inside that directory. + // - Patterns without a `/` are prefixed with `**/` to give them matchBase + // semantics (e.g. `*.tmp` → `**/*.tmp`), matching the basename in any + // subdirectory as gitignore does. + // - Patterns that already contain a `/` (but don't end with one) are + // path-specific and used as-is. + private _normalisePattern(pattern: string): string { + if (pattern.endsWith("/")) { + return "**/" + pattern + "**"; + } else if (!pattern.includes("/")) { + return "**/" + pattern; + } + return pattern; + } + + private async _importGitignore(): Promise { + const gitignorePath = path.join(this.vaultPath, ".gitignore"); + let content: string; + try { + content = await fs.readFile(gitignorePath, "utf-8"); + } catch { + return; + } + this._parseLines(content); + } + + private _parseLines(content: string): void { + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + this._addPattern(trimmed); + } + } + + private _addPattern(raw: string): void { + if (raw.startsWith("!")) { + throw new Error( + `[IgnoreRules] Negation pattern '${raw}' is not supported. ` + + `Remove it from .livesync/ignore or use a separate include/exclude file.` + ); + } + this.patterns.push(this._normalisePattern(raw)); + } + + /** + * Returns `true` if the given vault-relative path matches any loaded + * ignore pattern. + * + * @param relativePath - Path relative to the vault root, using forward + * slashes or the OS separator. + */ + shouldIgnore(relativePath: string): boolean { + if (this.patterns.length === 0) { + return false; + } + // Normalise to forward slashes for minimatch. + const normalised = relativePath.replace(/\\/g, "/"); + return this.patterns.some((p) => minimatch(normalised, p, { dot: true })); + } +} diff --git a/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts b/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts new file mode 100644 index 0000000..4f6a606 --- /dev/null +++ b/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts @@ -0,0 +1,169 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { IgnoreRules } from "./IgnoreRules"; + +describe("IgnoreRules", () => { + const tempDirs: string[] = []; + + async function createVault(): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-ignorerules-")); + tempDirs.push(tempDir); + return tempDir; + } + + async function writeIgnoreFile(vaultPath: string, content: string): Promise { + const ignoreDir = path.join(vaultPath, ".livesync"); + await fs.mkdir(ignoreDir, { recursive: true }); + await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8"); + } + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + }); + + describe("pattern normalisation", () => { + it("adds **/ prefix to basename patterns (no slash)", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true); + expect(rules.shouldIgnore("scratch.tmp")).toBe(true); + expect(rules.shouldIgnore("deep/nested/file.tmp")).toBe(true); + }); + + it("appends ** to directory patterns ending with / and prepends **/", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "build/\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("build/output.js")).toBe(true); + expect(rules.shouldIgnore("build/nested/file.js")).toBe(true); + expect(rules.shouldIgnore("subproject/build/output.js")).toBe(true); + }); + + it("leaves patterns containing / as-is", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "docs/private.md\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("docs/private.md")).toBe(true); + expect(rules.shouldIgnore("other/docs/private.md")).toBe(false); + }); + }); + + describe("shouldIgnore", () => { + it("matches **/*.tmp against notes/scratch.tmp", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true); + }); + + it("does not match notes/readme.md against **/*.tmp", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("notes/readme.md")).toBe(false); + }); + + it("returns false when no patterns are loaded", async () => { + const vaultPath = await createVault(); + const rules = new IgnoreRules(vaultPath); + // No load() call — patterns are empty + expect(rules.shouldIgnore("anything.md")).toBe(false); + }); + }); + + describe("negation patterns", () => { + it("throws when a negation pattern is encountered", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\n!important.tmp\n"); + const rules = new IgnoreRules(vaultPath); + await expect(rules.load()).rejects.toThrow(/Negation pattern/); + }); + + it("throws when a .gitignore imported via directive contains negation", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "import: .gitignore\n"); + await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n!keep.log\n", "utf-8"); + const rules = new IgnoreRules(vaultPath); + await expect(rules.load()).rejects.toThrow(/Negation pattern/); + }); + }); + + describe("unrecognised import: directives", () => { + it("warns and skips unrecognised import: forms (does not add as literal pattern)", async () => { + const vaultPath = await createVault(); + // Typo: "import:.gitignore" instead of "import: .gitignore" + await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + // *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern) + expect(rules.shouldIgnore("scratch.tmp")).toBe(true); + expect(rules.shouldIgnore("import:.gitignore")).toBe(false); + }); + }); + + describe("load() with missing file", () => { + it("returns without error when .livesync/ignore is absent", async () => { + const vaultPath = await createVault(); + // No ignore file created + const rules = new IgnoreRules(vaultPath); + await expect(rules.load()).resolves.toBeUndefined(); + expect(rules.shouldIgnore("anything.md")).toBe(false); + }); + }); + + describe("load() with comments and blank lines", () => { + it("skips # comment lines and blank lines", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("scratch.tmp")).toBe(true); + expect(rules.shouldIgnore("build/output.js")).toBe(true); + expect(rules.shouldIgnore("readme.md")).toBe(false); + }); + }); + + describe("import: .gitignore directive", () => { + it("reads and normalises patterns from .gitignore", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "import: .gitignore\n"); + await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\nnode_modules/\n", "utf-8"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("app.log")).toBe(true); + expect(rules.shouldIgnore("node_modules/package.json")).toBe(true); + expect(rules.shouldIgnore("src/node_modules/package.json")).toBe(true); + expect(rules.shouldIgnore("src/index.ts")).toBe(false); + }); + + it("merges .gitignore patterns with other patterns", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n"); + await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n", "utf-8"); + const rules = new IgnoreRules(vaultPath); + await rules.load(); + expect(rules.shouldIgnore("scratch.tmp")).toBe(true); + expect(rules.shouldIgnore("error.log")).toBe(true); + }); + }); + + describe("import: .gitignore with missing .gitignore", () => { + it("does not throw when .gitignore is absent", async () => { + const vaultPath = await createVault(); + await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n"); + // No .gitignore created + const rules = new IgnoreRules(vaultPath); + await expect(rules.load()).resolves.toBeUndefined(); + // The *.tmp pattern from the ignore file still works + expect(rules.shouldIgnore("scratch.tmp")).toBe(true); + }); + }); +}); diff --git a/src/apps/cli/test/test-daemon-linux.sh b/src/apps/cli/test/test-daemon-linux.sh new file mode 100755 index 0000000..96db2c7 --- /dev/null +++ b/src/apps/cli/test/test-daemon-linux.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# Test: daemon-related ignore rules behaviour +# +# Tests that are runnable without a long-running daemon process are exercised +# here using the `mirror` command, which calls the same `isTargetFile` handler +# stack that the daemon uses. +# +# Covered cases: +# 1. .livesync/ignore with *.tmp pattern → ignored file is NOT synced to DB +# 2. .livesync/ignore missing → no error, normal sync continues +# 3. import: .gitignore directive → patterns from .gitignore are merged +# +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +cd "$CLI_DIR" +source "$SCRIPT_DIR/test-helpers.sh" +display_test_info + +RUN_BUILD="${RUN_BUILD:-1}" +cli_test_init_cli_cmd + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-daemon-test.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +SETTINGS_FILE="$WORK_DIR/data.json" +VAULT_DIR="$WORK_DIR/vault" +mkdir -p "$VAULT_DIR/notes" + +if [[ "$RUN_BUILD" == "1" ]]; then + echo "[INFO] building CLI..." + npm run build +fi + +echo "[INFO] generating settings -> $SETTINGS_FILE" +cli_test_init_settings_file "$SETTINGS_FILE" +cli_test_mark_settings_configured "$SETTINGS_FILE" + +PASS=0 +FAIL=0 + +assert_pass() { echo "[PASS] $1"; PASS=$((PASS + 1)); } +assert_fail() { echo "[FAIL] $1" >&2; FAIL=$((FAIL + 1)); } + +# ───────────────────────────────────────────────────────────────────────────── +# Case 1: .livesync/ignore with *.tmp → matched file should NOT appear in DB +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Case 1: .livesync/ignore *.tmp → ignored file not synced to DB ===" + +mkdir -p "$VAULT_DIR/.livesync" +printf '*.tmp\n' > "$VAULT_DIR/.livesync/ignore" + +# Also write a normal file so we can confirm mirror ran at all. +printf 'normal content\n' > "$VAULT_DIR/notes/normal.md" +# Write the file that should be ignored. +printf 'tmp content\n' > "$VAULT_DIR/notes/scratch.tmp" + +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror + +# The normal file should be in the DB. +RESULT_NORMAL="$WORK_DIR/case1-normal.txt" +if run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" pull notes/normal.md "$RESULT_NORMAL" 2>/dev/null; then + if cmp -s "$VAULT_DIR/notes/normal.md" "$RESULT_NORMAL"; then + assert_pass "normal.md was synced to DB" + else + assert_fail "normal.md content mismatch after mirror" + fi +else + assert_fail "normal.md was not found in DB after mirror" +fi + +# The .tmp file should NOT be in the DB. +DB_LIST="$WORK_DIR/case1-ls.txt" +run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" ls > "$DB_LIST" +if grep -q "scratch.tmp" "$DB_LIST"; then + assert_fail "scratch.tmp (ignored) was unexpectedly synced to DB" + echo "--- DB listing ---" >&2; cat "$DB_LIST" >&2 +else + assert_pass "scratch.tmp (*.tmp pattern) was NOT synced to DB" +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Case 2: .livesync/ignore absent → no error, normal sync continues +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Case 2: .livesync/ignore absent → no error, sync continues ===" + +VAULT_DIR2="$WORK_DIR/vault2" +mkdir -p "$VAULT_DIR2/notes" +SETTINGS_FILE2="$WORK_DIR/data2.json" +cli_test_init_settings_file "$SETTINGS_FILE2" +cli_test_mark_settings_configured "$SETTINGS_FILE2" + +# No .livesync directory at all. +printf 'hello\n' > "$VAULT_DIR2/notes/hello.md" + +# mirror should succeed without error. +set +e +MIRROR_OUTPUT="$WORK_DIR/case2-mirror.txt" +run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" mirror >"$MIRROR_OUTPUT" 2>&1 +MIRROR_EXIT=$? +set -e + +if [[ "$MIRROR_EXIT" -ne 0 ]]; then + assert_fail "mirror exited non-zero ($MIRROR_EXIT) when .livesync/ignore is absent" + cat "$MIRROR_OUTPUT" >&2 +else + assert_pass "mirror succeeded when .livesync/ignore is absent" +fi + +# The normal file should have been synced. +RESULT_HELLO="$WORK_DIR/case2-hello.txt" +if run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" pull notes/hello.md "$RESULT_HELLO" 2>/dev/null; then + assert_pass "file synced normally when .livesync/ignore is absent" +else + assert_fail "file was not synced when .livesync/ignore is absent" +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Case 3: import: .gitignore merges patterns +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "=== Case 3: import: .gitignore directive merges patterns ===" + +VAULT_DIR3="$WORK_DIR/vault3" +mkdir -p "$VAULT_DIR3/notes" +SETTINGS_FILE3="$WORK_DIR/data3.json" +cli_test_init_settings_file "$SETTINGS_FILE3" +cli_test_mark_settings_configured "$SETTINGS_FILE3" + +mkdir -p "$VAULT_DIR3/.livesync" +printf 'import: .gitignore\n' > "$VAULT_DIR3/.livesync/ignore" +printf '# gitignore comment\n*.log\nbuild/\n' > "$VAULT_DIR3/.gitignore" + +printf 'regular note\n' > "$VAULT_DIR3/notes/regular.md" +printf 'log content\n' > "$VAULT_DIR3/notes/debug.log" + +run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" mirror + +DB_LIST3="$WORK_DIR/case3-ls.txt" +run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" ls > "$DB_LIST3" + +if grep -q "debug.log" "$DB_LIST3"; then + assert_fail "debug.log (ignored via .gitignore import) was unexpectedly synced to DB" + echo "--- DB listing ---" >&2; cat "$DB_LIST3" >&2 +else + assert_pass "debug.log (*.log from imported .gitignore) was NOT synced to DB" +fi + +# regular.md should still be present. +if grep -q "regular.md" "$DB_LIST3"; then + assert_pass "regular.md was synced normally alongside .gitignore import rules" +else + assert_fail "regular.md was NOT synced — .gitignore import may have been too broad" +fi + +# ───────────────────────────────────────────────────────────────────────────── +# Summary +# ───────────────────────────────────────────────────────────────────────────── +echo "" +echo "Results: PASS=$PASS FAIL=$FAIL" +if [[ "$FAIL" -gt 0 ]]; then + exit 1 +fi diff --git a/src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts b/src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts index 6f5244b..0c0151a 100644 --- a/src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts +++ b/src/apps/cli/testdeno/test-e2e-two-vaults-couchdb.ts @@ -29,7 +29,8 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : ""; const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : ""; - const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : ""; + const minioEndpoint = + remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : ""; const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : ""; const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : ""; const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : ""; diff --git a/src/apps/cli/vite.config.ts b/src/apps/cli/vite.config.ts index e78642c..11104cd 100644 --- a/src/apps/cli/vite.config.ts +++ b/src/apps/cli/vite.config.ts @@ -11,11 +11,55 @@ const defaultExternal = [ "crypto", "pouchdb-adapter-leveldb", "commander", + "chokidar", "punycode", "werift", ]; +// Polyfill FileReader at the very top of the CJS bundle. octagonal-wheels uses +// FileReader for base64 conversion when Uint8Array.toBase64 (TC39 proposal) is +// unavailable. Node.js has neither, so we inject a minimal FileReader shim before +// any module-scope code evaluates. +const fileReaderPolyfillBanner = ` +if (typeof globalThis.FileReader === "undefined") { + globalThis.FileReader = class FileReader { + constructor() { this.result = null; this.onload = null; this.onerror = null; } + readAsDataURL(blob) { + blob.arrayBuffer().then((buf) => { + var b64 = require("buffer").Buffer.from(buf).toString("base64"); + this.result = "data:" + (blob.type || "application/octet-stream") + ";base64," + b64; + if (this.onload) this.onload({ target: this }); + }).catch((err) => { if (this.onerror) this.onerror({ target: this, error: err }); }); + } + readAsArrayBuffer() { throw new Error("FileReader.readAsArrayBuffer is not implemented in this polyfill"); } + readAsBinaryString() { throw new Error("FileReader.readAsBinaryString is not implemented in this polyfill"); } + readAsText() { throw new Error("FileReader.readAsText is not implemented in this polyfill"); } + abort() { throw new Error("FileReader.abort is not implemented in this polyfill"); } + }; +} +`; + +function injectBanner(): import("vite").Plugin { + return { + name: "inject-banner", + generateBundle(_options, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) { + // Insert after the shebang line if present, otherwise at the top. + if (chunk.code.startsWith("#!")) { + const newline = chunk.code.indexOf("\n"); + chunk.code = + chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1); + } else { + chunk.code = fileReaderPolyfillBanner + chunk.code; + } + } + } + }, + }; +} + export default defineConfig({ - plugins: [svelte()], + plugins: [svelte(), injectBanner()], resolve: { alias: { "@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts", diff --git a/src/apps/webapp/bootstrap.ts b/src/apps/webapp/bootstrap.ts index 2450285..b3fa072 100644 --- a/src/apps/webapp/bootstrap.ts +++ b/src/apps/webapp/bootstrap.ts @@ -41,7 +41,7 @@ async function renderHistoryList(): Promise { const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]); - listEl.innerHTML = ""; + listEl.replaceChildren(); emptyEl.classList.toggle("is-hidden", items.length > 0); for (const item of items) { diff --git a/src/common/reportTool.ts b/src/common/reportTool.ts new file mode 100644 index 0000000..8eef539 --- /dev/null +++ b/src/common/reportTool.ts @@ -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, 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; + } + // 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 = {}; + 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; + 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; +} diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts b/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts new file mode 100644 index 0000000..aa2cd94 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationModal.ts @@ -0,0 +1,80 @@ +import { App, Modal } from "@/deps.ts"; +import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte"; +import { mount, unmount } from "svelte"; +import type { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; + +export type P2POpenReplicationModalCallback = { + onSync: (peerId: string) => Promise; + onSyncAndClose: (peerId: string) => Promise; +}; + +export class P2POpenReplicationModal extends Modal { + liveSyncReplicator: LiveSyncTrysteroReplicator; + callback?: P2POpenReplicationModalCallback; + component?: ReturnType; + showResult: boolean; + title: string; + onClosed?: () => void; + rebuildMode: boolean; + + constructor( + app: App, + liveSyncReplicator: LiveSyncTrysteroReplicator, + callback?: P2POpenReplicationModalCallback, + showResult: boolean = false, + title: string = "P2P Replication", + onClosed?: () => void, + rebuildMode: boolean = false + ) { + super(app); + this.liveSyncReplicator = liveSyncReplicator; + this.callback = callback; + this.showResult = showResult; + this.title = title; + this.onClosed = onClosed; + this.rebuildMode = rebuildMode; + } + + async onSync(peerId: string) { + if (this.callback?.onSync) { + await this.callback.onSync(peerId); + } + } + + async onSyncAndClose(peerId: string) { + if (this.callback?.onSyncAndClose) { + await this.callback.onSyncAndClose(peerId); + } + this.close(); + } + + override onOpen() { + const { contentEl } = this; + this.titleEl.setText(this.title); + contentEl.empty(); + + if (this.component === undefined) { + this.component = mount(P2POpenReplicationPane, { + target: contentEl, + props: { + liveSyncReplicator: this.liveSyncReplicator, + onSync: (peerId: string) => this.onSync(peerId), + onSyncAndClose: (peerId: string) => this.onSyncAndClose(peerId), + onClose: () => this.close(), + showResult: this.showResult, + rebuildMode: this.rebuildMode, + }, + }); + } + } + + override onClose() { + const { contentEl } = this; + contentEl.empty(); + if (this.component !== undefined) { + void unmount(this.component); + this.component = undefined; + } + this.onClosed?.(); + } +} diff --git a/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte new file mode 100644 index 0000000..58c7e99 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2POpenReplicationPane.svelte @@ -0,0 +1,313 @@ + + +
+ + +
+

Available Peers

+ {#if serverInfo && serverInfo.knownAdvertisements.length > 0} +
+ {#each serverInfo.knownAdvertisements as peer (peer.peerId)} +
+
+
{peer.name}
+
+ {peer.platform} + + {peer.peerId.slice(0, 8)} + + + {getAcceptanceStatus(peer)} + +
+
+
+ {#if !rebuildMode} + + + {:else} + + {/if} +
+
+ {/each} +
+ {:else if serverInfo} +

No devices available. Waiting for other devices to connect...

+ {/if} +
+ + +
+ + diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts new file mode 100644 index 0000000..ae5f02f --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts @@ -0,0 +1,131 @@ +import { App } from "@/deps.ts"; +import { Logger } from "@lib/common/logger"; +import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types"; +import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; +import { P2POpenReplicationModal } from "./P2POpenReplicationModal"; + +/** + * Creates an openReplicationUI factory for Obsidian environments. + * Returns a per-replicator closure that opens the P2P Replication modal + * and performs bidirectional sync (pull then push on success). + * + * Usage: + * const factory = createOpenReplicationUI(app); + * useP2PReplicatorFeature(core, factory); + */ +export function createOpenReplicationUI( + app: App +): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise { + return (replicator: LiveSyncTrysteroReplicator) => + (showResult: boolean): Promise => { + const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; + return new Promise((resolve) => { + const modal = new P2POpenReplicationModal( + app, + replicator, + { + onSync: async (peerId: string) => { + try { + // pull (replicateFrom) first; push only on success + const pullResult = await replicator.replicateFrom(peerId, showResult); + if (pullResult?.ok) { + const pushResult = await replicator.requestSynchroniseToPeer(peerId); + resolve(pushResult?.ok ?? true); + } else { + resolve(false); + } + } catch (e) { + Logger( + `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, + logLevel + ); + resolve(false); + } + }, + onSyncAndClose: async (peerId: string) => { + try { + const pullResult = await replicator.replicateFrom(peerId, showResult); + if (pullResult?.ok) { + const pushResult = await replicator.requestSynchroniseToPeer(peerId); + if (pushResult?.ok ?? true) { + await replicator.close(); + resolve(true); + } else { + resolve(false); + } + } else { + resolve(false); + } + } catch (e) { + Logger( + `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, + logLevel + ); + resolve(false); + } + }, + }, + showResult + ); + modal.open(); + }); + }; +} + +/** + * Creates an openRebuildUI factory for Obsidian environments. + * Opens the P2P Replication modal in "rebuild" mode — one-way pull only, + * with setOnSetup / clearOnSetup bracketing the replicateFrom call. + * + * Usage: + * const factory = createOpenRebuildUI(app); + * useP2PReplicatorFeature(core, createOpenReplicationUI(app), factory); + */ +export function createOpenRebuildUI( + app: App +): (replicator: LiveSyncTrysteroReplicator) => (showResult: boolean) => Promise { + return (replicator: LiveSyncTrysteroReplicator) => + (showResult: boolean): Promise => { + const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; + return new Promise((resolve) => { + let resolved = false; + const safeResolve = (val: boolean) => { + if (!resolved) { + resolved = true; + resolve(val); + } + }; + + const doRebuild = async (peerId: string) => { + replicator.setOnSetup(); + try { + Logger(`Rebuilding from peer ${peerId}`, logLevel); + const result = await replicator.replicateFrom(peerId, showResult); + safeResolve(result?.ok ?? false); + } catch (e) { + Logger( + `Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`, + logLevel + ); + safeResolve(false); + } finally { + replicator.clearOnSetup(); + } + }; + + const modal = new P2POpenReplicationModal( + app, + replicator, + { + onSync: doRebuild, + onSyncAndClose: doRebuild, + }, + showResult, + "P2P Rebuild", + () => safeResolve(false), + true + ); + modal.open(); + }); + }; +} diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte index 9dd7c8e..948a20e 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte @@ -5,20 +5,21 @@ AcceptedStatus, ConnectionStatus, type PeerStatus, - } from "../../../lib/src/replication/trystero/P2PReplicatorPaneCommon"; - import type { LiveSyncTrysteroReplicator } from "../../../lib/src/replication/trystero/LiveSyncTrysteroReplicator"; + } from "@lib/replication/trystero/P2PReplicatorPaneCommon"; + import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; import PeerStatusRow from "../P2PReplicator/PeerStatusRow.svelte"; - import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events"; + import { EVENT_LAYOUT_READY, eventHub } from "@/common/events"; import { type PeerInfo, type P2PServerInfo, EVENT_SERVER_STATUS, EVENT_REQUEST_STATUS, EVENT_P2P_REPLICATOR_STATUS, - } from "../../../lib/src/replication/trystero/TrysteroReplicatorP2PServer"; - import { type P2PReplicatorStatus } from "../../../lib/src/replication/trystero/TrysteroReplicator"; - import { $msg as _msg } from "../../../lib/src/common/i18n"; - import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types"; + } from "@lib/replication/trystero/TrysteroReplicatorP2PServer"; + import { type P2PReplicatorStatus } from "@lib/replication/trystero/TrysteroReplicator"; + import { $msg as _msg } from "@lib/common/i18n"; + import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types"; + import { generateP2PRoomId } from "@lib/common/utils"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; interface Props { @@ -148,6 +149,7 @@ eventHub.emitEvent(EVENT_REQUEST_STATUS); return () => { r(); + rx(); r2(); r3(); }; @@ -216,18 +218,8 @@ function useDefaultRelay() { eRelay = DEFAULT_SETTINGS.P2P_relays; } - function _generateRandom() { - return (Math.floor(Math.random() * 1000) + 1000).toString().substring(1); - } - function generateRandom(length: number) { - let buf = ""; - while (buf.length < length) { - buf += "-" + _generateRandom(); - } - return buf.substring(1, length); - } function chooseRandom() { - eRoomId = generateRandom(12) + "-" + Math.random().toString(36).substring(2, 5); + eRoomId = generateP2PRoomId(); } async function openServer() { @@ -251,7 +243,7 @@ setting?: boolean; }; return initialDialogStatus; - } catch (e) { + } catch { return {}; } }; diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte new file mode 100644 index 0000000..0d8aad6 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte @@ -0,0 +1,310 @@ + + +
+

Signalling Status

+ +
+ Connection: + + {isConnected ? "🟢 Connected" : "🔴 Disconnected"} + +
+ +
+ {#if !isConnected} + + {:else} + + {/if} +
+ + {#if serverInfo} +
+ Room ID suffix: + + {roomSuffix || "-"} + +
+ +
+ Peer ID: + + {serverInfo.serverPeerId.slice(0, 12)}... + +
+ +
+ Devices: + {serverInfo.knownAdvertisements.length} +
+ {/if} + + {#if showBroadcastToggle} +
+ + + +
+ {/if} + + {#if core} +
+ + +
+ {/if} + + {#if serverInfo} +
+

Stats

+
+
+ Incoming: + {serverInfo.diag.totalNewConnections} +
+
+ Connected: + {serverInfo.diag.totalSuccessfulConnections} +
+
+ Failed: + {serverInfo.diag.totalFailedConnections} +
+
+ Closed: + {serverInfo.diag.totalClosedConnections} +
+
+
+ {/if} +
+ + \ No newline at end of file diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte new file mode 100644 index 0000000..2ed575c --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte @@ -0,0 +1,891 @@ + + +
+
+

P2P Status

+
+
+ + +
+ +
+
+ + {#if !canEditP2PSettings()} +

Please select an active P2P remote configuration to change P2P sync targets.

+ {/if} + + + +
+
+

Detected Peers

+ +
+ + {#if serverInfo && serverInfo.knownAdvertisements.length > 0} +
+ {#each serverInfo.knownAdvertisements as peer (peer.peerId)} +
+
+
+ {peer.name} : + ({peer.peerId.slice(0, 8)}) + {#if isCommunicating(peer.peerId)} + 📡 + {/if} +
+
+ {peer.platform} +
+
+
+ {#if isAccepted(peer)} +
+ + {getAcceptanceStatus(peer)} + + + +
+
+ WATCH + +
+
+ SYNC + +
+ {:else} +
+ + {getAcceptanceStatus(peer)} + +
+
+ PERMANENT + + +
+
+ SESSION + + +
+ {/if} + {#if !isAccepted(peer) && (peer.isAccepted !== undefined || peer.isTemporaryAccepted !== undefined)} + + {/if} +
+
+ {/each} +
+ {:else if serverInfo} +

No devices available. Waiting for other devices to connect...

+ {:else} +

Fetching status...

+ {/if} +
+
+ + diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts new file mode 100644 index 0000000..a182b8a --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts @@ -0,0 +1,43 @@ +import { WorkspaceLeaf } from "@/deps.ts"; +import { mount } from "svelte"; +import { SvelteItemView } from "@/common/SvelteItemView.ts"; +import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; +import type { P2PPaneParams } from "@/lib/src/replication/trystero/UseP2PReplicatorResult"; +import P2PServerStatusPane from "./P2PServerStatusPane.svelte"; + +export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status"; + +export class P2PServerStatusPaneView extends SvelteItemView { + core: LiveSyncBaseCore; + private _p2pResult: P2PPaneParams; + override icon = "waypoints"; + override navigation = false; + + constructor(leaf: WorkspaceLeaf, core: LiveSyncBaseCore, p2pResult: P2PPaneParams) { + super(leaf); + this.core = core; + this._p2pResult = p2pResult; + } + + override getIcon(): string { + return "waypoints"; + } + + getViewType() { + return VIEW_TYPE_P2P_SERVER_STATUS; + } + + getDisplayText() { + return "P2P Status"; + } + + instantiateComponent(target: HTMLElement) { + return mount(P2PServerStatusPane, { + target, + props: { + liveSyncReplicator: this._p2pResult.replicator, + core: this.core, + }, + }); + } +} diff --git a/src/lib b/src/lib index 91b5981..e97ca58 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 91b59812191dc8e190658b4110eedd4dca5e1803 +Subproject commit e97ca5821c6fd50b22e63acbbc0f936e0ac8391a diff --git a/src/main.ts b/src/main.ts index a07ce40..6c5c3f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ -import { Notice, Plugin, type App, type PluginManifest } from "./deps"; - +import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps"; +import { setGetLanguage } from "./lib/src/common/coreEnvFunctions.ts"; +setGetLanguage(getLanguage); import { LiveSyncCommands } from "./features/LiveSyncCommands.ts"; import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts"; import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts"; @@ -43,6 +44,7 @@ import { useSetupManagerHandlersFeature } from "./serviceFeatures/setupObsidian/ import { useP2PReplicatorFeature } from "@lib/replication/trystero/useP2PReplicatorFeature.ts"; import { useP2PReplicatorCommands } from "@lib/replication/trystero/useP2PReplicatorCommands.ts"; import { useP2PReplicatorUI } from "./serviceFeatures/useP2PReplicatorUI.ts"; +import { createOpenReplicationUI, createOpenRebuildUI } from "./features/P2PSync/P2PReplicator/P2PReplicationUI.ts"; export type LiveSyncCore = LiveSyncBaseCore; export default class ObsidianLiveSyncPlugin extends Plugin { core: LiveSyncCore; @@ -175,7 +177,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const curriedFeature = () => featuresInitialiser(core); core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature); const setupManager = core.getModule(SetupManager); - + const replicator = useP2PReplicatorFeature( + core, + createOpenReplicationUI(this.app), + createOpenRebuildUI(this.app) + ); + useP2PReplicatorCommands(core, replicator); + useP2PReplicatorUI(core, core, replicator); useRemoteConfiguration(core); useSetupProtocolFeature(core, setupManager); @@ -189,9 +197,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { // VIEW_TYPE_P2P, // (leaf: any) => new P2PReplicatorPaneView(leaf, core, p2pReplicatorResult!), // ]); - const replicator = useP2PReplicatorFeature(core); - useP2PReplicatorCommands(core, replicator); - useP2PReplicatorUI(core, core, replicator); } ); } diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index 79b7b49..ddc59d0 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -1,3 +1,4 @@ +import type PouchDB from "pouchdb-core"; import { fireAndForget } from "octagonal-wheels/promises"; import { AbstractModule } from "../AbstractModule"; import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger"; diff --git a/src/modules/core/ModuleReplicatorP2P.ts b/src/modules/core/ModuleReplicatorP2P.ts deleted file mode 100644 index 7f59794..0000000 --- a/src/modules/core/ModuleReplicatorP2P.ts +++ /dev/null @@ -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 = {}): Promise { - 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)); - } -} diff --git a/src/modules/coreFeatures/ModuleRedFlag.ts b/src/modules/coreFeatures/ModuleRedFlag.ts deleted file mode 100644 index d57cb0f..0000000 --- a/src/modules/coreFeatures/ModuleRedFlag.ts +++ /dev/null @@ -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 { - 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, 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 { - 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 { - 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)); - } -} diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts index fab0091..4c95bbf 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts @@ -2,9 +2,11 @@ import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { extractObject } from "octagonal-wheels/object"; import { TweakValuesShouldMatchedTemplate, + TweakValuesTemplate, IncompatibleChanges, confName, type TweakValues, + type ObsidianLiveSyncSettings, type RemoteDBSettings, IncompatibleChangesInSpecificPattern, CompatibleButLossyChanges, @@ -14,8 +16,107 @@ import { AbstractModule } from "../AbstractModule.ts"; import { $msg } from "../../lib/src/common/i18n.ts"; import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts"; import type { LiveSyncCore } from "../../main.ts"; +import { REMOTE_P2P } from "@lib/common/models/setting.const.ts"; + +function valueToString(value: any) { + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return `${value}`; +} export class ModuleResolvingMismatchedTweaks extends AbstractModule { + private _hasNotifiedAutoAcceptCompatibleUndefined = false; + + private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial) { + const items = Object.keys( + TweakValuesShouldMatchedTemplate + ) as (keyof typeof TweakValuesShouldMatchedTemplate)[]; + return items.filter((key) => current[key] !== preferred[key]); + } + + private _selectNewerTweakSide(current: TweakValues, preferred: Partial): "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, + 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 { if (!this.core.replicator.tweakSettingsMismatched && !this.core.replicator.preferredTweakValue) return false; const preferred = this.core.replicator.preferredTweakValue; @@ -26,10 +127,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (ret == "IGNORE") return true; } - async _checkAndAskResolvingMismatchedTweaks( - preferred: Partial - ): Promise<[TweakValues | boolean, boolean]> { - const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings); + async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> { + const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues; + const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred); + const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys); + if (autoAcceptSide === "REMOTE") { + return [{ ...mine, ...preferred }, false]; + } + if (autoAcceptSide === "CURRENT") { + return [true, false]; + } const items = Object.entries(TweakValuesShouldMatchedTemplate); let rebuildRequired = false; let rebuildRecommended = false; @@ -68,8 +175,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { tableRows.push( $msg("TweakMismatchResolve.Table.Row", { name: confName(key), - self: valueMine, - remote: valuePreferred, + self: valueToString(valueMine), + remote: valueToString(valuePreferred), }) ); } @@ -136,9 +243,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (!tweaks) { return "IGNORE"; } - const preferred = extractObject(TweakValuesShouldMatchedTemplate, tweaks); - - const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferred); + const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks); if (!conf) return "IGNORE"; if (conf === true) { @@ -146,10 +251,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (rebuildRequired) { await this.core.rebuilder.$rebuildRemote(); } - Logger( - `Tweak values on the remote server have been updated. Your other device will see this message.`, - LOG_LEVEL_NOTICE - ); + Logger($msg("TweakMismatchResolve.Message.remoteUpdated"), LOG_LEVEL_NOTICE); return "CHECKAGAIN"; } if (conf) { @@ -159,7 +261,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { if (rebuildRequired) { await this.core.rebuilder.$fetchLocal(); } - Logger(`Configuration has been updated as configured by the other device.`, LOG_LEVEL_NOTICE); + Logger($msg("TweakMismatchResolve.Message.mineUpdated"), LOG_LEVEL_NOTICE); return "CHECKAGAIN"; } return "IGNORE"; @@ -186,6 +288,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { async _checkAndAskUseRemoteConfiguration( trialSetting: RemoteDBSettings ): Promise<{ result: false | TweakValues; requireFetch: boolean }> { + if (trialSetting.remoteType === REMOTE_P2P) { + return { result: false, requireFetch: false }; + } const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting); if (preferred) { return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred); @@ -197,6 +302,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { trialSetting: RemoteDBSettings, preferred: TweakValues ): Promise<{ result: false | TweakValues; requireFetch: boolean }> { + const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues; + const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred); + const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys); + if (autoAcceptSide === "REMOTE") { + return { result: { ...trialSetting, ...preferred }, requireFetch: false }; + } + if (autoAcceptSide === "CURRENT") { + return { result: false, requireFetch: false }; + } + const items = Object.entries(TweakValuesShouldMatchedTemplate); let rebuildRequired = false; let rebuildRecommended = false; @@ -207,8 +322,8 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { // const items = [mine,preferred] for (const v of items) { const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate; - const remoteValueForDisplay = escapeMarkdownValue(preferred[key]); - const currentValueForDisplay = `${escapeMarkdownValue((trialSetting as TweakValues)?.[key])}`; + const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key])); + const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key])); if ((trialSetting as TweakValues)?.[key] !== preferred[key]) { if (IncompatibleChanges.indexOf(key) !== -1) { rebuildRequired = true; @@ -285,6 +400,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { } override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void { + services.setting.onBeforeSaveSettingData.addHandler(this._onBeforeSaveSettingData.bind(this)); services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this)); services.tweakValue.checkAndAskResolvingMismatched.setHandler( this._checkAndAskResolvingMismatchedTweaks.bind(this) diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts new file mode 100644 index 0000000..4415891 --- /dev/null +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -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 = {}) { + 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; + + 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; + + 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(); + }); +}); diff --git a/src/modules/essential/ModuleInitializerFile.ts b/src/modules/essential/ModuleInitializerFile.ts deleted file mode 100644 index b26ac88..0000000 --- a/src/modules/essential/ModuleInitializerFile.ts +++ /dev/null @@ -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(); - - 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 { - this._log("Opening the key-value database", LOG_LEVEL_VERBOSE); - const isInitialized = (await this.core.kvDB.get("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 = (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; - 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; - 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; - 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 (procedureName: string, objects: T[], callback: (arg: T) => Promise) => { - 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; - }[] = []; - 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 { - 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 { - 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)); - } -} diff --git a/src/modules/essentialObsidian/ModuleCheckRemoteSize_obsolete.ts b/src/modules/essentialObsidian/ModuleCheckRemoteSize_obsolete.ts deleted file mode 100644 index 7d1c9a3..0000000 --- a/src/modules/essentialObsidian/ModuleCheckRemoteSize_obsolete.ts +++ /dev/null @@ -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 { - this.settings.notifyThresholdOfRemoteStorageSize = 1; - return this._allScanStat(); - } - - private async _allScanStat(): Promise { - 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 { - 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)); - } -} diff --git a/src/modules/essentialObsidian/ModuleObsidianEvents.ts b/src/modules/essentialObsidian/ModuleObsidianEvents.ts index 7445e11..33f0485 100644 --- a/src/modules/essentialObsidian/ModuleObsidianEvents.ts +++ b/src/modules/essentialObsidian/ModuleObsidianEvents.ts @@ -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(); } diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 742e873..df57cf5 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -1,6 +1,6 @@ import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts"; import { getPathFromTFile, isValidPath } from "../../../common/utils.ts"; -import { decodeBinary, escapeStringToHTML, readString } from "../../../lib/src/string_and_binary/convert.ts"; +import { decodeBinary, readString } from "../../../lib/src/string_and_binary/convert.ts"; import ObsidianLiveSyncPlugin from "../../../main.ts"; import { type DocumentID, @@ -96,7 +96,7 @@ 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; } } @@ -153,22 +153,87 @@ export class DocumentHistoryModal extends Modal { return v; } + prepareContentView(usePreformatted = true) { + this.contentView.empty(); + this.contentView.toggleClass("op-pre", usePreformatted); + } + + appendTextDiff(diff: [number, string][]) { + for (const [operation, text] of diff) { + if (operation == DIFF_DELETE) { + this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-deleted" }), text); + } else if (operation == DIFF_EQUAL) { + this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-normal" }), text); + } else if (operation == DIFF_INSERT) { + this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-added" }), text); + } + } + } + + appendSearchHighlightedText(container: HTMLElement, text: string) { + if (!this.searchKeyword) { + container.appendText(text); + return; + } + const escapedKeyword = this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedKeyword, "gi"); + let lastIndex = 0; + for (const match of text.matchAll(regex)) { + const index = match.index ?? 0; + if (index > lastIndex) { + container.appendText(text.slice(lastIndex, index)); + } + container.createEl("mark", { text: match[0] }); + lastIndex = index + match[0].length; + } + if (lastIndex < text.length) { + container.appendText(text.slice(lastIndex)); + } + } + + appendImageDiff(baseSrc: string, overlaySrc?: string) { + const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" }); + const overlay = wrap.createDiv({ cls: "overlay" }); + overlay.createEl("img", { cls: "img-base" }, (img) => { + img.src = baseSrc; + }); + if (overlaySrc) { + overlay.createEl("img", { cls: "img-overlay" }, (img) => { + img.src = overlaySrc; + }); + } + } + + appendDeletedNotice(usePreformatted = true) { + const notice = "(At this revision, the file has been deleted)"; + if (usePreformatted) { + this.contentView.appendText(`${notice}\n`); + } else { + this.contentView.createDiv({ text: notice }); + } + } + async showExactRev(rev: string) { const db = this.core.localDatabase; const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true); this.currentText = ""; this.currentDeleted = false; + this.prepareContentView(); if (w === false) { this.currentDeleted = true; - this.info.innerHTML = ""; - this.contentView.innerHTML = `Could not read this revision
(${rev})`; + this.info.empty(); + this.contentView.appendText("Could not read this revision"); + this.contentView.createEl("br"); + this.contentView.appendText(`(${rev})`); } else { this.currentDoc = w; - this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`; - let result = undefined; + this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`); const w1data = readDocument(w); this.currentDeleted = !!w.deleted; - // this.currentText = w1data; + if (typeof w1data == "string") { + this.currentText = w1data; + } + let rendered = false; if (this.showDiff) { const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1); if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) { @@ -176,72 +241,55 @@ export class DocumentHistoryModal extends Modal { const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true); if (w2 != false) { if (typeof w1data == "string") { - result = ""; - const dmp = new diff_match_patch(); - const w2data = readDocument(w2) as string; - const diff = dmp.diff_main(w2data, w1data); - dmp.diff_cleanupSemantic(diff); - for (const v of diff) { - const x1 = v[0]; - const x2 = v[1]; - let text = escapeStringToHTML(x2); - if (this.searchKeyword) { - const regex = new RegExp( - `(${this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, - "gi" - ); - text = text.replace(regex, "$1"); - } - if (x1 == DIFF_DELETE) { - result += "" + text + ""; - } else if (x1 == DIFF_EQUAL) { - result += "" + text + ""; - } else if (x1 == DIFF_INSERT) { - result += "" + text + ""; + const w2data = readDocument(w2); + if (typeof w2data == "string") { + const dmp = new diff_match_patch(); + const diff = dmp.diff_main(w2data, w1data); + dmp.diff_cleanupSemantic(diff); + if (this.currentDeleted) { + this.appendDeletedNotice(); } + this.appendTextDiff(diff); + rendered = true; } - result = result.replace(/\n/g, "
"); } else if (isImage(this.file)) { const src = this.generateBlobURL("base", w1data); const overlay = this.generateBlobURL( "overlay", readDocument(w2) as Uint8Array ); - result = `
-
- - -
-
`; - this.contentView.removeClass("op-pre"); + this.prepareContentView(false); + if (this.currentDeleted) { + this.appendDeletedNotice(false); + } + this.appendImageDiff(src, overlay); + rendered = true; } } } } - if (result == undefined) { + if (!rendered) { if (typeof w1data != "string") { if (isImage(this.file)) { const src = this.generateBlobURL("base", w1data); - result = `
-
- -
-
`; - this.contentView.removeClass("op-pre"); + this.prepareContentView(false); + if (this.currentDeleted) { + this.appendDeletedNotice(false); + } + this.appendImageDiff(src); + } else { + if (this.currentDeleted) { + this.appendDeletedNotice(); + } + this.contentView.appendText("Binary file"); } } else { - result = escapeStringToHTML(w1data); + if (this.currentDeleted) { + this.appendDeletedNotice(); + } + this.appendSearchHighlightedText(this.contentView, w1data); } } - if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file"; - - if (this.searchKeyword && typeof result == "string" && !this.showDiff) { - const regex = new RegExp(`(${this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi"); - result = result.replace(regex, "$1"); - } - - this.contentView.innerHTML = - (this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result; } // Reset diff navigation after content changes this.resetDiffNavigation(); @@ -272,15 +320,14 @@ export class DocumentHistoryModal extends Modal { if (direction === "next") { this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length; } else { - this.currentDiffIndex = - this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1; + this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1; } const target = diffElements[this.currentDiffIndex]; target.classList.add("diff-focused"); target.scrollIntoView({ behavior: "smooth", block: "center" }); - this.diffNavIndicator.textContent = `${this.currentDiffIndex + 1}/${diffElements.length}`; + this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`); } /** @@ -291,9 +338,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(); @@ -317,8 +364,8 @@ export class DocumentHistoryModal extends Modal { this.currentSearchIndex = -1; if (!keyword) { - this.searchResultIndicator.textContent = ""; - this.searchProgressIndicator.textContent = ""; + this.searchResultIndicator.setText(""); + this.searchProgressIndicator.setText(""); return; } @@ -327,7 +374,7 @@ export class DocumentHistoryModal extends Modal { const totalRevs = this.revs_info.length; const end = Math.min(totalRevs, limit); - this.searchProgressIndicator.textContent = "Searching..."; + this.searchProgressIndicator.setText("Searching..."); const dmp = new diff_match_patch(); @@ -336,7 +383,7 @@ export class DocumentHistoryModal extends Modal { const revInfo = this.revs_info[i]; const rev = revInfo.rev; - this.searchProgressIndicator.textContent = `Searching ${i + 1}/${end}...`; + this.searchProgressIndicator.setText(`Searching ${i + 1}/${end}...`); const doc = await db.getDBEntry(this.file, { rev: rev }, false, false, true); if (doc === false) continue; @@ -364,8 +411,10 @@ export class DocumentHistoryModal extends Modal { 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)) { + if ( + (d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) && + d[1].toLocaleLowerCase().includes(keywordLower) + ) { foundInDiff = true; break; } @@ -379,16 +428,16 @@ export class DocumentHistoryModal extends Modal { } } - this.searchProgressIndicator.textContent = "Done"; + this.searchProgressIndicator.setText("Done"); this.updateSearchUI(); } updateSearchUI() { if (this.searchResults.length === 0) { - this.searchResultIndicator.textContent = this.searchKeyword ? "No matches found" : ""; + this.searchResultIndicator.setText(this.searchKeyword ? "No matches found" : ""); } else { const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0; - this.searchResultIndicator.textContent = `${current}/${this.searchResults.length} matches`; + this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`); } } @@ -406,7 +455,7 @@ export class DocumentHistoryModal extends Modal { 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? @@ -425,13 +474,10 @@ export class DocumentHistoryModal extends Modal { const searchRow = contentEl.createDiv(""); searchRow.addClass("op-info"); searchRow.addClass("search-row"); - searchRow.style.display = "flex"; - searchRow.style.gap = "5px"; - searchRow.style.alignItems = "center"; - searchRow.style.marginBottom = "10px"; + searchRow.addClass("history-search-row"); const searchInput = searchRow.createEl("input", { type: "text", placeholder: "Search in history (last 100)..." }); - searchInput.style.flexGrow = "1"; + searchInput.addClass("history-search-input"); searchInput.addEventListener("input", () => { if (this.searchTimeout) { clearTimeout(this.searchTimeout); @@ -451,12 +497,10 @@ export class DocumentHistoryModal extends Modal { }); this.searchResultIndicator = searchRow.createEl("span", { text: "" }); - this.searchResultIndicator.style.fontSize = "0.8em"; - this.searchResultIndicator.style.minWidth = "80px"; + this.searchResultIndicator.addClass("history-search-result-indicator"); this.searchProgressIndicator = searchRow.createEl("span", { text: "" }); - this.searchProgressIndicator.style.fontSize = "0.8em"; - this.searchProgressIndicator.style.color = "var(--text-muted)"; + this.searchProgressIndicator.addClass("history-search-progress-indicator"); const divView = contentEl.createDiv(""); divView.addClass("op-flex"); @@ -473,31 +517,24 @@ export class DocumentHistoryModal extends Modal { const diffOptionsRow = contentEl.createDiv(""); diffOptionsRow.addClass("op-info"); diffOptionsRow.addClass("diff-options-row"); - diffOptionsRow.style.display = "flex"; - diffOptionsRow.style.justifyContent = "space-between"; - diffOptionsRow.style.alignItems = "center"; + diffOptionsRow.addClass("history-diff-options-row"); const highlightDiffContainer = diffOptionsRow.createDiv(""); - highlightDiffContainer.style.display = "flex"; - highlightDiffContainer.style.alignItems = "center"; + highlightDiffContainer.addClass("history-highlight-diff-container"); highlightDiffContainer.createEl("label", {}, (label) => { - label.style.display = "flex"; - label.style.alignItems = "center"; - label.style.gap = "4px"; - 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()); - }); - }) - ); + 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"); }); @@ -505,7 +542,6 @@ export class DocumentHistoryModal extends Modal { this.diffNavContainer = diffOptionsRow.createDiv(""); this.diffNavContainer.addClass("diff-nav"); this.diffNavContainer.style.display = this.showDiff ? "flex" : "none"; - this.diffNavContainer.style.marginLeft = "auto"; this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => { e.addClass("diff-nav-btn"); diff --git a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts index ad308e5..eec5332 100644 --- a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts +++ b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts @@ -1,7 +1,6 @@ import { App, Modal } from "../../../deps.ts"; import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch"; import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts"; -import { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts"; import { delay } from "../../../lib/src/common/utils.ts"; import { eventHub } from "../../../common/events.ts"; import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts"; @@ -44,6 +43,25 @@ export class ConflictResolveModal extends Modal { // sendValue("close-resolve-conflict:" + this.filename, false); } + appendDiffFragment(container: HTMLDivElement, text: string, cls: string) { + const lines = text.split("\n"); + lines.forEach((line, index) => { + const span = container.createSpan({ cls }); + span.textContent = line; + if (index < lines.length - 1) { + container.createSpan({ cls: "ls-mark-cr" }); + container.createEl("br"); + } + }); + } + + appendVersionInfo(container: HTMLDivElement, cls: string, name: string, date: string) { + const line = container.createSpan({ cls }); + line.createSpan({ text: name, cls: "conflict-dev-name" }); + line.appendText(`: ${date}`); + container.createEl("br"); + } + override onOpen() { const { contentEl } = this; // Send cancel signal for the previous merge dialogue @@ -64,25 +82,21 @@ export class ConflictResolveModal extends Modal { const div = contentEl.createDiv(""); div.addClass("op-scrollable"); div.addClass("ls-dialog"); - let diff = ""; + let diffLength = 0; for (const v of this.result.diff) { const x1 = v[0]; const x2 = v[1]; + diffLength += x2.length; + if (diffLength > 100 * 1024) { + continue; + } if (x1 == DIFF_DELETE) { - diff += - "" + - escapeStringToHTML(x2).replace(/\n/g, "\n") + - ""; + this.appendDiffFragment(div, x2, "deleted"); + div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" }); } else if (x1 == DIFF_EQUAL) { - diff += - "" + - escapeStringToHTML(x2).replace(/\n/g, "\n") + - ""; + this.appendDiffFragment(div, x2, "normal"); } else if (x1 == DIFF_INSERT) { - diff += - "" + - escapeStringToHTML(x2).replace(/\n/g, "\n") + - ""; + this.appendDiffFragment(div, x2, "added"); } } @@ -92,8 +106,8 @@ export class ConflictResolveModal extends Modal { new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : ""); const date2 = new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : ""); - div2.innerHTML = `${this.localName}: ${date1}
-${this.remoteName}: ${date2}
`; + 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"; @@ -108,11 +122,9 @@ export class ConflictResolveModal extends Modal { contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => e.addEventListener("click", () => this.sendResponse(CANCELLED)) ).style.marginRight = "4px"; - diff = diff.replace(/\n/g, "
"); - if (diff.length > 100 * 1024) { + if (diffLength > 100 * 1024) { + div.empty(); div.innerText = "(Too large diff to display)"; - } else { - div.innerHTML = diff; } } diff --git a/src/modules/features/ModuleLog.ts b/src/modules/features/ModuleLog.ts index 16de2d9..1eb7945 100644 --- a/src/modules/features/ModuleLog.ts +++ b/src/modules/features/ModuleLog.ts @@ -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, mark: string) { const formatted = reactiveSource(""); - let timer: ReturnType | 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 { @@ -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 { @@ -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) { diff --git a/src/modules/features/SettingDialogue/PaneAdvanced.ts b/src/modules/features/SettingDialogue/PaneAdvanced.ts index 2ac6cf0..34c58e8 100644 --- a/src/modules/features/SettingDialogue/PaneAdvanced.ts +++ b/src/modules/features/SettingDialogue/PaneAdvanced.ts @@ -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 }) diff --git a/src/modules/features/SettingDialogue/PaneChangeLog.ts b/src/modules/features/SettingDialogue/PaneChangeLog.ts index be0e0c3..3d8ceb9 100644 --- a/src/modules/features/SettingDialogue/PaneChangeLog.ts +++ b/src/modules/features/SettingDialogue/PaneChangeLog.ts @@ -43,10 +43,13 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem // tmpDiv.addClass("sls-header-button"); tmpDiv.addClass("op-warn-info"); - tmpDiv.innerHTML = `

${$msg("obsidianLiveSyncSettingTab.msgNewVersionNote")}

`; + tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") }); + const readEverythingButton = tmpDiv.createEl("button", { + text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"), + }); if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) { const informationButtonDiv = informationDivEl.appendChild(tmpDiv); - informationButtonDiv.querySelector("button")?.addEventListener("click", () => { + readEverythingButton.addEventListener("click", () => { fireAndForget(async () => { this.editingSettings.lastReadUpdates = lastVersion; await this.saveAllDirtySettings(); diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index ebfb377..d9ca27c 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -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) diff --git a/src/modules/features/SettingDialogue/PaneSetup.ts b/src/modules/features/SettingDialogue/PaneSetup.ts index de996bc..4e365c9 100644 --- a/src/modules/features/SettingDialogue/PaneSetup.ts +++ b/src/modules/features/SettingDialogue/PaneSetup.ts @@ -121,13 +121,13 @@ export function paneSetup( const repo = "vrtmrz/obsidian-livesync"; const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting"); const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`; - this.createEl( - paneEl, - "div", - "", - (el) => - (el.innerHTML = `${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}`) - ); + this.createEl(paneEl, "div", "", (el) => { + el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => { + anchor.href = `https://github.com/${repo}/blob/main${topPath}`; + anchor.target = "_blank"; + anchor.rel = "noopener"; + }); + }); const troubleShootEl = this.createEl(paneEl, "div", { text: "", cls: "sls-troubleshoot-preview", diff --git a/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts b/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts index 041c923..d061643 100644 --- a/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts +++ b/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts @@ -13,7 +13,7 @@ export const checkConfig = async ( Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO); let isSuccessful = true; const emptyDiv = createDiv(); - emptyDiv.innerHTML = ""; + emptyDiv.createSpan(); checkResultDiv?.replaceChildren(...[emptyDiv]); const addResult = (msg: string, classes?: string[]) => { const tmpDiv = createDiv(); @@ -21,7 +21,7 @@ export const checkConfig = async ( if (classes) { tmpDiv.addClasses(classes); } - tmpDiv.innerHTML = `${msg}`; + tmpDiv.textContent = msg; checkResultDiv?.appendChild(tmpDiv); }; try { @@ -47,9 +47,10 @@ export const checkConfig = async ( if (!checkResultDiv) return; const tmpDiv = createDiv(); tmpDiv.addClass("ob-btn-config-fix"); - tmpDiv.innerHTML = ``; + tmpDiv.createEl("label", { text: title }); + const fixButton = tmpDiv.createEl("button", { text: $msg("obsidianLiveSyncSettingTab.btnFix") }); const x = checkResultDiv.appendChild(tmpDiv); - x.querySelector("button")?.addEventListener("click", () => { + fixButton.addEventListener("click", () => { fireAndForget(async () => { Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value })); const res = await requestToCouchDBWithCredentials( diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index 1bd40be..3d69968 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -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); diff --git a/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte b/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte index 58ad125..57c0621 100644 --- a/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte +++ b/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte @@ -4,10 +4,10 @@ import Decision from "@/lib/src/UI/components/Decision.svelte"; import Instruction from "@/lib/src/UI/components/Instruction.svelte"; import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte"; - const TYPE_CLOSE = "close"; + const TYPE_CLOSE = "close"; type ResultType = typeof TYPE_CLOSE; type Props = { - setResult: (result: ResultType) => void; + setResult: (_result: ResultType) => void; }; const { setResult }: Props = $props(); diff --git a/src/modules/features/SetupWizard/dialogs/SelectMethodNewUser.svelte b/src/modules/features/SetupWizard/dialogs/SelectMethodNewUser.svelte index 2478521..b4dd9ea 100644 --- a/src/modules/features/SetupWizard/dialogs/SelectMethodNewUser.svelte +++ b/src/modules/features/SetupWizard/dialogs/SelectMethodNewUser.svelte @@ -48,6 +48,8 @@ bind:value={userType} > This is an advanced option for users who do not have a URI or who wish to configure detailed settings. + You can also select this option if you intend to use P2P (Peer-to-Peer) synchronisation + instead of a CouchDB/S3 server — P2P requires no server setup at all. diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte index b07c157..cd2020e 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte @@ -1,13 +1,13 @@