Compare commits

..

57 Commits

Author SHA1 Message Date
vorotamoroz
0e6dd300ef Merge branch 'fix_warns' into improve_first_fetch 2026-06-01 11:22:01 +01:00
vorotamoroz
8171db353a bump 2026-06-01 11:19:42 +01:00
vorotamoroz
6ab1556880 prettify 2026-06-01 11:19:32 +01:00
vorotamoroz
cd2bff5fc7 Refactor types in svelte components. 2026-06-01 11:18:23 +01:00
vorotamoroz
5a280c7919 (feat): Bulk database fetching is now work in progress. This feature is expected to speed up rebuilds and setups. (WIP) 2026-06-01 10:41:48 +01:00
vorotamoroz
c6697327d5 Fixed typings
Fixed wrong typing for serviceHub, svelte dialog
2026-06-01 06:20:33 +01:00
vorotamoroz
7c203a522a Aligned to eslint rules and fixed following things:
This commit may contains behavioural changes.

- Fix for the issue with corrupted log displays
- Wrap the activeDocument
- Reduced potential type errors and strengthened certain checks
- Made error handling more robust (by rewriting the error class)
2026-06-01 05:28:03 +01:00
vorotamoroz
c80c294d93 satisfies
Address following rules
-
- @typescript-eslint/unbound-method
- obsidianmd/prefer-active-doc
- obsidianmd/prefer-window-timer

Improve typing
2026-06-01 04:20:47 +01:00
vorotamoroz
3e65ae932d Address following rules
- @typescript-eslint/no-redundant-type-constituents
2026-06-01 03:50:51 +01:00
vorotamoroz
f710f03380 Address following rules
- @typescript-eslint/no-redundant-type-constituents
- @typescript-eslint/no-unnecessary-type-assertion
2026-06-01 03:37:55 +01:00
vorotamoroz
b887269fc1 change eslint for more progressive 2026-06-01 02:37:07 +01:00
vorotamoroz
56a234e6d7 chore: wrap timer functions 2026-05-31 19:03:43 +09:00
vorotamoroz
39014b2294 use coreEnvVars for some vars 2026-05-30 23:59:36 +09:00
vorotamoroz
24e6c110a3 Remove unused imports 2026-05-30 23:53:19 +09:00
vorotamoroz
f24d110552 Change type assertion 2026-05-30 23:49:32 +09:00
vorotamoroz
7189c1c05a Update dependency
set no-deprecated to warn
2026-05-30 23:42:32 +09:00
vorotamoroz
547afe9a86 remove unused eslint comment
use _fetch instead of fetch
add ignore list
2026-05-30 23:32:46 +09:00
vorotamoroz
7b5876037d Add ignores 2026-05-30 23:22:17 +09:00
vorotamoroz
b714c00644 (chore): Improve type assertion, remove unused imports 2026-05-29 04:19:58 +01:00
vorotamoroz
e14e771bfb (chore): tidied tsconfig and eslint, and some incorrect imports 2026-05-28 04:42:03 +01:00
vorotamoroz
1130bbcee8 remove unmaintained tests 2026-05-26 11:33:14 +01:00
vorotamoroz
8841ef4619 fix css duplicated props 2026-05-26 11:27:31 +01:00
vorotamoroz
45fe0b3682 chore: lint: enable cache and concurrency 2026-05-26 11:24:41 +01:00
vorotamoroz
8d3825abc9 Merge pull request #925 from vrtmrz/v0_25_70
Releasing v0.25.70
2026-05-25 18:38:04 +09:00
vorotamoroz
c5f9841b85 chore: fix grammatical error 2026-05-25 10:14:18 +01:00
vorotamoroz
d36d176d99 bump 2026-05-25 10:10:46 +01:00
vorotamoroz
38b2cf73ed Update lib (forgot to include #942) 2026-05-25 09:55:11 +01:00
vorotamoroz
40b15a6950 Merge pull request #924 from vrtmrz/p2p_add_fix_and_diag
Improvements: More diagnostic information for P2P connections
2026-05-25 17:52:37 +09:00
vorotamoroz
e312bb7640 Merge pull request #891 from SeleiXi/feat/conflict-diff-jump
feat: add diff navigation to conflict resolver
2026-05-25 17:51:49 +09:00
Ching Wing Kwok
852c0e6c13 Merge branch 'main' into feat/conflict-diff-jump 2026-05-25 15:38:21 +08:00
vorotamoroz
7c1bcf9e9b Merge pull request #889 from SeleiXi/diff-only-button
feat: add diff-only view button to document history
2026-05-25 14:00:11 +09:00
vorotamoroz
2b79bed085 Merge branch 'main' into pr/SeleiXi/889 2026-05-25 05:43:36 +01:00
vorotamoroz
6b1e0c4aa8 Merge pull request #890 from SeleiXi/feat/history-search
feat: Add document history search
2026-05-25 12:55:29 +09:00
vorotamoroz
3c3645eba4 Improved: More diagnostic information for P2P connections is now shown, including why a connection failure occurred and the current connection status. 2026-05-25 04:31:22 +01:00
SeleiXi
009cc3c87a Merge remote-tracking branch 'origin/main' into feat/conflict-diff-jump
# Conflicts:
#	src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts
2026-05-23 02:06:52 +08:00
SeleiXi
fc5fd4be94 Merge remote-tracking branch 'origin/main' into feat/history-search
# Conflicts:
#	src/lib
#	src/modules/features/DocumentHistory/DocumentHistoryModal.ts
2026-05-23 02:05:33 +08:00
SeleiXi
8ed1acf79d Merge remote-tracking branch 'origin/main' into diff-only-button
# Conflicts:
#	src/modules/features/DocumentHistory/DocumentHistoryModal.ts
2026-05-23 02:04:48 +08:00
vorotamoroz
c518223d21 Merge pull request #923 from vrtmrz/p2p_add_fix_and_diag
0.25.69: Fix P2P, add diag feature
2026-05-23 00:47:36 +09:00
vorotamoroz
caaff618e9 fix grammar 2026-05-22 16:40:40 +01:00
vorotamoroz
148aa8505e bump 2026-05-22 16:38:44 +01:00
vorotamoroz
f9a626a858 ### Fixed
- No longer the P2P passphrase mismatch causes a server shutdown.
- Settings related to P2P synchronisation are now correctly applied on start-up and no longer reverted.

### New features
- Diagnostic P2P connection stats are now available.
  - These stats indicate the number of connection trials, successes, and, failures.
2026-05-22 16:37:05 +01:00
vorotamoroz
1b8747115c Merge pull request #922 from vrtmrz/update_trystero
Releasing v0.25.68 (dependency and P2P update)
2026-05-22 19:17:22 +09:00
vorotamoroz
e739302fb9 bump 2026-05-22 11:11:18 +01:00
vorotamoroz
8f20d53f55 Update trystero, typed well. fixed some potentially problems
Weaken terser for libraries
2026-05-22 11:01:51 +01:00
vorotamoroz
fd84b0377b Update actions 2026-05-21 10:02:46 +01:00
vorotamoroz
340d416b76 Merge pull request #920 from vrtmrz/v0_25_66
Release v0.25.67
2026-05-20 19:44:41 +09:00
vorotamoroz
3034af8d69 bump again 2026-05-20 11:37:51 +01:00
vorotamoroz
da3020bd45 fixed: fix auto-correction mismatch 2026-05-20 11:34:28 +01:00
vorotamoroz
ce232c1002 bump 2026-05-20 11:14:41 +01:00
vorotamoroz
0e13926400 fixed: update lib
I should do this with #919
2026-05-20 11:10:43 +01:00
vorotamoroz
fab7ec996a Merge pull request #919 from vrtmrz:feat_cli_remote_select
feat: add CLI commands to handle multiple remote configuration
2026-05-20 19:05:49 +09:00
vorotamoroz
88e22f99c5 Merge pull request #917 from vrtmrz:feat_tweak_auto_adjust
feat: implement auto-accept compatible tweak
2026-05-20 19:02:58 +09:00
vorotamoroz
1167b41340 feat: add CLI commands to handle multiple remote configuration 2026-05-20 05:10:42 +01:00
SeleiXi
5454e1106f feat: add diff navigation to conflict resolver 2026-05-13 00:19:56 +08:00
SeleiXi
0d9397c8b9 fix: resolve UI alignment issue for diff navigation buttons 2026-05-12 00:52:20 +08:00
SeleiXi
429a3ff1fd feat: add diff-only view button to document history 2026-05-11 23:53:07 +08:00
SeleiXi
bfff6ea7b8 feat: add document history search support 2026-05-11 22:45:42 +08:00
69 changed files with 2009 additions and 1667 deletions

View File

@@ -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:

View File

@@ -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'

3
.gitignore vendored
View File

@@ -30,4 +30,5 @@ cov_profile/**
coverage
src/apps/cli/dist/*
_testdata/**
utils/bench/splitResults.csv
utils/bench/splitResults.csv
.eslintcache

View File

@@ -17,7 +17,7 @@ The plugin uses a dynamic module system to reduce coupling and improve maintaina
- `coreObsidian/` - Obsidian-specific core (e.g., `ModuleFileAccessObsidian`)
- `essential/` - Required modules (e.g., `ModuleMigration`, `ModuleKeyValueDB`)
- `features/` - Optional features (e.g., `ModuleLog`, `ModuleObsidianSettings`)
- `extras/` - Development/testing tools (e.g., `ModuleDev`, `ModuleIntegratedTest`)
- `extras/` - Development/testing tools (e.g., `ModuleDev`, ~~`ModuleIntegratedTest`~~)
- **Services**: Core services (e.g., `database`, `replicator`, `storageAccess`) are registered in `ServiceHub` and accessed by modules. They provide an extension point for add new behaviour without modifying existing code.
- For example, checks before the replication can be added to the `replication.onBeforeReplicate` handler, and the handlers can be return `false` to prevent replication-starting. `vault.isTargetFile` also can be used to prevent processing specific files.
- **ServiceModule**: A new type of module that directly depends on services.

View File

@@ -3,81 +3,132 @@ import obsidianmd from "eslint-plugin-obsidianmd";
import globals from "globals";
import { defineConfig, globalIgnores } from "eslint/config";
import * as sveltePlugin from "eslint-plugin-svelte";
import svelteParser from "svelte-eslint-parser";
const warnWhileDev = "off"; // Change to "warn" to enable warnings for rules that are currently disabled.
export default defineConfig([
globalIgnores([
"**/node_modules/*",
"**/jest.config.js",
// Build outputs and legacy files
"**/build",
"coverage",
"**/main.js",
"main_org.js",
"pouchdb-browser.js",
"version-bump.mjs",
"package.json",
"**/*.json",
"**/.eslintrc.js.bak",
// Files from linked dependencies (those files should not exist for most people).
"modules/octagonal-wheels/dist/**/*",
// Sub-projects (Exclude from root linting as they have different environments)
"src/apps/**/*",
"utils/**/*",
// Specific exclusions from common library (src/lib)
"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/patches/pouchdb-utils",
"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",
// Config files and build scripts
"**/jest.config.js",
"**/rollup.config.js",
"**/esbuild.config.mjs",
"**/terser.*.mjs",
".prettierrc.*.mjs",
".prettierrc.mjs",
"*.config.mjs",
"vite.*",
"vitest.*",
// Testing files (Simplified patterns)
"test/**",
"**/*.test.ts",
"**/*.unit.spec.ts",
"**/test.ts",
"**/tests.ts",
]),
...sveltePlugin.configs["flat/base"],
...obsidianmd.configs.recommended,
{
files: ["**/*.ts"],
// ignores:["src/lib/**/*.ts"], // Exclude library files from root linting (they have different environments and rules).
languageOptions: {
globals: { ...globals.browser },
globals: { ...globals.browser, PouchDB: "readonly" },
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
},
},
linterOptions:{
reportUnusedDisableDirectives: false,
},
rules: {
// -- Base rules (turned off in favour of TS specific versions or explicitly disabled).
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
"no-unused-labels": "off",
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off",
"require-await": "error",
"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",
"require-await": "off",
// -- TypeScript specific rules
// @typescript-eslint/no-unsafe-* rules and @typescript-eslint/no-explicit-any:
// This project contains a lot of library-sh code where the use of `any` is often necessary and justified.
// Rules is now set to 'off' for a while.
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
// -- Reasonable rules.
"@typescript-eslint/no-deprecated": warnWhileDev,
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
// -- Obsidian rules
// obsidianmd/no-unsupported-api: usually this project checks for API support at runtime, so this rule is not critical but can be helpful to catch potential issues.
"obsidianmd/no-unsupported-api": warnWhileDev,
// -- General rules
"no-async-promise-executor": warnWhileDev,
"no-constant-condition": ["error", { checkLoops: false }],
// -- Disabled rules
// no-undef: This option breaks the global declarations for the library files and is not worth the effort to fix at this time.
"no-undef": "off",
// -- Plugin specific overrides
"obsidianmd/rule-custom-message": "off",
"obsidianmd/ui/sentence-case": "off",
"obsidianmd/no-plugin-as-component": "off",
// -- Temporary overrides for migration
"obsidianmd/no-static-styles-assignment": "off",
},
},
{
files: ["**/*.svelte"],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: tsParser,
extraFileExtensions: [".svelte"],
},
},
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"obsidianmd/no-plugin-as-component": "off", // Temporary
// no-unused-vars:
// Svelte template's declarations have a lot of false positives and the rule is not worth the effort to fix at this time.
// it may improve in the future with some options as like ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],]
"no-unused-vars": "off",
"obsidianmd/no-plugin-as-component": "off",
"obsidianmd/ui/sentence-case": "off",
},
},
]);

View File

@@ -1,10 +1,10 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "0.25.65",
"version": "0.25.70-patch1",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
"authorUrl": "https://github.com/vrtmrz",
"isDesktopOnly": false
}
}

60
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "0.25.65",
"version": "0.25.70-patch1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "0.25.65",
"version": "0.25.70-patch1",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -15,7 +15,8 @@
"@smithy/middleware-apply-body-checksum": "^4.3.9",
"@smithy/protocol-http": "^5.3.9",
"@smithy/querystring-builder": "^4.2.9",
"@trystero-p2p/nostr": "^0.23.0",
"@smithy/util-retry": "^4.4.5",
"@trystero-p2p/nostr": "^0.24.0",
"chokidar": "^4.0.0",
"commander": "^14.0.3",
"diff-match-patch": "^1.0.5",
@@ -25,10 +26,10 @@
"micromatch": "^4.0.0",
"minimatch": "^10.2.2",
"obsidian": "^1.12.3",
"octagonal-wheels": "^0.1.45",
"octagonal-wheels": "^0.1.46",
"pouchdb-adapter-leveldb": "^9.0.0",
"qrcode-generator": "^1.4.4",
"werift": "^0.22.9",
"werift": "^0.23.0",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
"devDependencies": {
@@ -2684,9 +2685,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/"
@@ -3546,9 +3547,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.24.3",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz",
"integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==",
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz",
"integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
@@ -4149,13 +4150,12 @@
}
},
"node_modules/@smithy/util-retry": {
"version": "4.2.12",
"resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz",
"integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==",
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.4.5.tgz",
"integrity": "sha512-W9Ovy9i02yGqtLlpqZNQuXNxXc5OPfXujnembxN/FxyBtGjJd8vKY0PQYEJ8FNybTOcXG+ZxsSsX23HOb3zQzg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/service-error-classification": "^4.2.12",
"@smithy/types": "^4.13.1",
"@smithy/core": "^3.24.5",
"tslib": "^2.6.2"
},
"engines": {
@@ -4297,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": {
@@ -11491,9 +11491,9 @@
"license": "MIT"
},
"node_modules/octagonal-wheels": {
"version": "0.1.45",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.45.tgz",
"integrity": "sha512-gXoCrwoUIXhmu57YN4BxAtBe+JaYNJNaXaZuVjqjopwYKpH5p2mn1om6KjA22rgGPiIJFXkse2U28FFXoT3/0Q==",
"version": "0.1.46",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.46.tgz",
"integrity": "sha512-19eB7b/WNNrZ4Xghu93f+NVJsbRiaZaIIzU1rn5shxb6SzwVBoOVkNPJdCAsONl6C1MwjaGDrPUS8CBXvPHjPg==",
"license": "MIT",
"dependencies": {
"idb": "^8.0.3"
@@ -16291,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",

View File

@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "0.25.65",
"version": "0.25.70-patch1",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -19,13 +19,13 @@
"buildVite": "npx dotenv-cli -e .env -- vite build --mode production",
"buildViteOriginal": "npx dotenv-cli -e .env -- vite build --mode original",
"buildDev": "node esbuild.config.mjs dev",
"lint": "eslint src",
"lint": "eslint --cache --concurrency auto src",
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
"tsc-check": "tsc --noEmit",
"pretty": "npm run prettyNoWrite -- --write --log-level error",
"prettyCheck": "npm run prettyNoWrite -- --check",
"prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ",
"check": "npm run lint && npm run svelte-check",
"check": "npm run tsc-check && npm run lint && npm run svelte-check",
"unittest": "deno test -A --no-check --coverage=cov_profile --v8-flags=--expose-gc --trace-leaks ./src/",
"test": "vitest run",
"test:unit": "vitest run --config vitest.config.unit.ts",
@@ -130,20 +130,21 @@
"@smithy/middleware-apply-body-checksum": "^4.3.9",
"@smithy/protocol-http": "^5.3.9",
"@smithy/querystring-builder": "^4.2.9",
"@trystero-p2p/nostr": "^0.23.0",
"@smithy/util-retry": "^4.4.5",
"@trystero-p2p/nostr": "^0.24.0",
"chokidar": "^4.0.0",
"commander": "^14.0.3",
"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",
"obsidian": "^1.12.3",
"octagonal-wheels": "^0.1.46",
"pouchdb-adapter-leveldb": "^9.0.0",
"qrcode-generator": "^1.4.4",
"werift": "^0.22.9",
"werift": "^0.23.0",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
}
}

View File

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

View File

@@ -3,6 +3,8 @@ import * as path from "path";
import { decodeSettingsFromSetupURI } from "@lib/API/processSetting";
import { configURIBase } from "@lib/common/models/shared.const";
import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types";
import { ConnectionStringParser } from "@lib/common/ConnectionString";
import { activateRemoteConfiguration, createRemoteConfigurationId } from "@lib/serviceFeatures/remoteConfig";
import { stripAllPrefixes } from "@lib/string_and_binary/path";
import type { CLICommandContext, CLIOptions } from "./types";
import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils";
@@ -10,6 +12,10 @@ import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./
import { performFullScan } from "@lib/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
function redactConnectionString(uri: string): string {
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
}
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
const { databasePath, core, settingsPath } = context;
@@ -469,5 +475,206 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
return await performFullScan(core as any, log, errorManager, false, true);
}
if (options.command === "remote-add") {
if (options.commandArgs.length < 2) {
throw new Error("remote-add requires two arguments: <name> <connstr>");
}
const name = options.commandArgs[0].trim();
const connectionString = options.commandArgs[1].trim();
if (!name) {
throw new Error("remote-add requires a non-empty name");
}
if (!connectionString) {
throw new Error("remote-add requires a non-empty connection string");
}
const parsed = ConnectionStringParser.parse(connectionString);
const canonicalUri = ConnectionStringParser.serialize(parsed);
const id = createRemoteConfigurationId();
let activated = false;
await core.services.setting.updateSettings((currentSettings) => {
currentSettings.remoteConfigurations ||= {};
currentSettings.remoteConfigurations[id] = {
id,
name,
uri: canonicalUri,
isEncrypted: false,
};
if (!currentSettings.activeConfigurationId) {
currentSettings.activeConfigurationId = id;
const applied = activateRemoteConfiguration(currentSettings, id);
activated = applied !== false;
}
return currentSettings;
}, true);
if (activated) {
await core.services.control.applySettings();
}
process.stdout.write(`${id}\t${name}\t${redactConnectionString(canonicalUri)}\n`);
return true;
}
if (options.command === "remote-rm") {
if (options.commandArgs.length < 1) {
throw new Error("remote-rm requires one argument: <remote-id>");
}
const id = options.commandArgs[0].trim();
if (!id) {
throw new Error("remote-rm requires a non-empty remote-id");
}
const current = core.services.setting.currentSettings();
if (!current.remoteConfigurations?.[id]) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
let switchedActive = false;
await core.services.setting.updateSettings((currentSettings) => {
const configs = currentSettings.remoteConfigurations || {};
delete configs[id];
currentSettings.remoteConfigurations = configs;
if (currentSettings.activeConfigurationId === id) {
const nextActiveId = Object.keys(configs)[0] || "";
currentSettings.activeConfigurationId = nextActiveId;
switchedActive = nextActiveId !== "";
if (nextActiveId !== "") {
activateRemoteConfiguration(currentSettings, nextActiveId);
}
}
if (currentSettings.P2P_ActiveRemoteConfigurationId === id) {
currentSettings.P2P_ActiveRemoteConfigurationId = "";
}
return currentSettings;
}, true);
if (switchedActive) {
await core.services.control.applySettings();
}
console.error(`[Command] remote-rm ${id}`);
return true;
}
if (options.command === "remote-ls") {
const settings = core.services.setting.currentSettings();
const configs = Object.values(settings.remoteConfigurations || {});
configs.sort((a, b) => a.name.localeCompare(b.name));
if (configs.length === 0) {
process.stderr.write("[Info] No remote configurations found.\n");
return true;
}
const lines = configs.map((config) => {
const status = config.id === settings.activeConfigurationId ? "active" : "inactive";
return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`;
});
process.stdout.write(lines.join("\n") + "\n");
return true;
}
if (options.command === "remote-export") {
if (options.commandArgs.length < 1) {
throw new Error("remote-export requires one argument: <remote-id>");
}
const id = options.commandArgs[0].trim();
if (!id) {
throw new Error("remote-export requires a non-empty remote-id");
}
const config = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!config) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
process.stdout.write(`${config.uri}\n`);
return true;
}
if (options.command === "remote-set") {
if (options.commandArgs.length < 2) {
throw new Error("remote-set requires two arguments: <remote-id> <connstr>");
}
const id = options.commandArgs[0].trim();
const connectionString = options.commandArgs[1].trim();
if (!id) {
throw new Error("remote-set requires a non-empty remote-id");
}
if (!connectionString) {
throw new Error("remote-set requires a non-empty connection string");
}
const parsed = ConnectionStringParser.parse(connectionString);
const canonicalUri = ConnectionStringParser.serialize(parsed);
let switchedActive = false;
await core.services.setting.updateSettings((currentSettings) => {
const config = currentSettings.remoteConfigurations?.[id];
if (!config) {
return currentSettings;
}
config.uri = canonicalUri;
if (currentSettings.activeConfigurationId === id) {
const activated = activateRemoteConfiguration(currentSettings, id);
switchedActive = activated !== false;
if (activated) {
return activated;
}
}
return currentSettings;
}, true);
const updated = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!updated) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`);
return false;
}
if (switchedActive) {
await core.services.control.applySettings();
}
console.error(`[Command] remote-set ${id}`);
return true;
}
if (options.command === "remote-activate") {
if (options.commandArgs.length < 1) {
throw new Error("remote-activate requires one argument: <remote-id>");
}
const id = options.commandArgs[0].trim();
if (!id) {
throw new Error("remote-activate requires a non-empty remote-id");
}
let switched = false;
await core.services.setting.updateSettings((currentSettings) => {
const activated = activateRemoteConfiguration(currentSettings, id);
if (activated) {
switched = true;
return activated;
}
return currentSettings;
}, true);
if (!switched) {
process.stderr.write(`[Info] Failed to activate remote configuration: ${id}\n`);
return false;
}
await core.services.control.applySettings();
console.error(`[Command] remote-activate ${id}`);
return true;
}
throw new Error(`Unsupported command: ${options.command}`);
}

View File

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

View File

@@ -20,6 +20,12 @@ export type CLICommand =
| "rm"
| "resolve"
| "mirror"
| "remote-add"
| "remote-rm"
| "remote-ls"
| "remote-export"
| "remote-set"
| "remote-activate"
| "init-settings";
export interface CLIOptions {
@@ -67,5 +73,11 @@ export const VALID_COMMANDS = new Set([
"rm",
"resolve",
"mirror",
"remote-add",
"remote-rm",
"remote-ls",
"remote-export",
"remote-set",
"remote-activate",
"init-settings",
] as const);

View File

@@ -62,6 +62,16 @@ Commands:
rm <path> Mark a file as deleted in local database
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
remote-add <name> <connstr>
Add a remote configuration from a connection string
remote-rm <remote-id> Remove a remote configuration by ID
remote-ls List stored remote configurations
remote-export <remote-id>
Export a remote connection string by ID
remote-set <remote-id> <connstr>
Replace a stored remote connection string by ID
remote-activate <remote-id>
Activate a stored remote configuration by ID
Options:
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
@@ -84,6 +94,12 @@ Examples:
livesync-cli ./my-database info notes/hello.md
livesync-cli ./my-database rm notes/hello.md
livesync-cli ./my-database resolve notes/hello.md 3-abcdef
livesync-cli ./my-database remote-add my-remote "sls+https://user:pass@example.com/db"
livesync-cli ./my-database remote-ls
livesync-cli ./my-database remote-export remote-abc123
livesync-cli ./my-database remote-set remote-abc123 "sls+s3://ak:sk@example.com/?endpoint=https%3A%2F%2Fs3.example.com&bucket=mybucket"
livesync-cli ./my-database remote-activate remote-abc123
livesync-cli ./my-database remote-rm remote-abc123
livesync-cli init-settings ./data.json
livesync-cli ./my-database --verbose
`);
@@ -229,6 +245,9 @@ export async function main() {
options.command === "cat" ||
options.command === "cat-rev" ||
options.command === "ls" ||
options.command === "remote-add" ||
options.command === "remote-ls" ||
options.command === "remote-export" ||
options.command === "p2p-peers" ||
options.command === "info" ||
options.command === "rm" ||

View File

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

View File

@@ -128,7 +128,7 @@ export const _requestToCouchDBFetch = async (
username: string,
password: string,
path?: string,
body?: string | any,
body?: any,
method?: string
) => {
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
@@ -146,7 +146,7 @@ export const _requestToCouchDBFetch = async (
contentType: "application/json",
body: JSON.stringify(body),
};
return await fetch(uri, requestParam);
return await _fetch(uri, requestParam);
};
export const _requestToCouchDB = async (
@@ -214,6 +214,7 @@ import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.cons
export { BASE_IS_NEW, EVEN, TARGET_IS_NEW };
// Why 2000? : ZIP FILE Does not have enough resolution.
import { compareMTime } from "@lib/common/utils.ts";
import { _fetch } from "@/lib/src/common/coreEnvFunctions.ts";
export { compareMTime };
function getKey(file: AnyEntry | string | UXFileInfoStub) {
const key = typeof file == "string" ? file : stripAllPrefixes(file.path);

View File

@@ -68,9 +68,10 @@ import { ConflictResolveModal } from "../../modules/features/InteractiveConflict
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG, eventHub } from "../../common/events.ts";
import { PluginDialogModal } from "./PluginDialogModal.ts";
import { $msg } from "src/lib/src/common/i18n.ts";
import { $msg } from "@/lib/src/common/i18n.ts";
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
import type { LiveSyncCore } from "../../main.ts";
import { LiveSyncError } from "@lib/common/LSError.ts";
const d = "\u200b";
const d2 = "\n";
@@ -564,7 +565,7 @@ export class ConfigSync extends LiveSyncCommands {
...data,
documentPath: this.getPath(wx),
files: xFiles,
} as PluginDataExDisplay;
} satisfies PluginDataExDisplay;
}
return false;
}
@@ -1069,10 +1070,10 @@ export class ConfigSync extends LiveSyncCommands {
}
const baseDir = this.configDir;
try {
if (!data.documentPath) throw "InternalError: Document path not exist";
if (!data.documentPath) throw new LiveSyncError("InternalError: Document path not exist");
const dx = await this.localDatabase.getDBEntry(data.documentPath);
if (dx == false) {
throw "Not found on database";
throw new LiveSyncError("Not found on database");
}
const loadedData = deserialize(getDocDataAsArray(dx.data), {}) as PluginDataEx;
for (const f of loadedData.files) {
@@ -1317,7 +1318,7 @@ export class ConfigSync extends LiveSyncCommands {
}
const docXDoc = await this.localDatabase.getDBEntryFromMeta(old, false, false);
if (docXDoc == false) {
throw "Could not load the document";
throw new LiveSyncError("Could not load the document");
}
const dataSrc = getDocData(docXDoc.data);
const dataStart = dataSrc.indexOf(DUMMY_END);

View File

@@ -50,6 +50,7 @@ import { hiddenFilesEventCount, hiddenFilesProcessingCount } from "../../lib/src
import { EVENT_SETTING_SAVED, eventHub } from "../../common/events.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import type { LiveSyncCore } from "../../main.ts";
import { tryGetFilePath } from "@lib/common/utils.doc.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
declare global {
@@ -317,7 +318,7 @@ export class HiddenFileSync extends LiveSyncCommands {
this._fileInfoLastProcessed.set(file, key);
}
async updateLastProcessedAsActualFile(file: FilePath, stat?: UXStat | null | undefined) {
async updateLastProcessedAsActualFile(file: FilePath, stat?: UXStat | null) {
if (!stat) stat = await this.core.storageAccess.statHidden(file);
this._fileInfoLastProcessed.set(file, this.statToKey(stat));
}
@@ -411,10 +412,7 @@ export class HiddenFileSync extends LiveSyncCommands {
}
}
async updateLastProcessedAsActualDatabase(
file: FilePath,
doc?: MetaEntry | LoadedEntry | null | undefined | false
) {
async updateLastProcessedAsActualDatabase(file: FilePath, doc?: MetaEntry | LoadedEntry | null | false) {
const dbPath = addPrefix(file, ICHeader);
if (!doc) doc = await this.localDatabase.getDBEntryMeta(dbPath);
if (!doc) return;
@@ -1050,7 +1048,7 @@ Offline Changed files: ${processFiles.length}`;
}
notifyProgress();
} catch (ex) {
this._log(`Failed to process storage change file:${file}`, logLevel);
this._log(`Failed to process storage change file:${tryGetFilePath(file)}`, logLevel);
this._log(ex, LOG_LEVEL_VERBOSE);
}
});
@@ -1162,7 +1160,7 @@ Offline Changed files: ${files.length}`;
await this.trackDatabaseFileModification(path, "[Scanning]", true, onlyNew, file);
notifyProgress();
} catch (ex) {
this._log(`Failed to process database changes:${file}`);
this._log(`Failed to process database changes:${tryGetFilePath(file)}`);
this._log(ex, LOG_LEVEL_VERBOSE);
}
return;
@@ -1500,7 +1498,7 @@ Offline Changed files: ${files.length}`;
}
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
const storeFilePath = stripAllPrefixes(file.path as FilePath);
const storeFilePath = stripAllPrefixes(file.path);
const storageFilePath = file.path;
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
return undefined;

View File

@@ -16,9 +16,8 @@ import { serialized } from "octagonal-wheels/concurrency/lock_v2";
import { arrayToChunkedArray } from "octagonal-wheels/collection";
import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@/common/events";
import type { LiveSyncCouchDBReplicator } from "@/lib/src/replication/couchdb/LiveSyncReplicator";
import { delay, parseHeaderValues } from "@/lib/src/common/utils";
import { generateCredentialObject } from "@/lib/src/replication/httplib";
import { _requestToCouchDB } from "@/common/utils";
import { delay } from "@/lib/src/common/utils";
// import { _requestToCouchDB } from "@/common/utils";
const DB_KEY_SEQ = "gc-seq";
const DB_KEY_CHUNK_SET = "chunk-set";
const DB_KEY_DOC_USAGE_MAP = "doc-usage-map";
@@ -391,7 +390,7 @@ Note: **Make sure to synchronise all devices before deletion.**
.map((revInfo) => db.get(doc._id, { rev: revInfo.rev }))
).then((docs) => docs.filter((doc) => doc));
for (const oldDoc of oldDocs) {
await processDoc(oldDoc as EntryDoc, false);
await processDoc(oldDoc, false);
}
}
} catch (ex) {
@@ -533,7 +532,7 @@ Success: ${successCount}, Errored: ${errored}`;
const docMap = new Map<DocumentID, Set<DocumentInfo>>();
const info = await db.info();
// Total number of revisions to process (approximate)
const maxSeq = new Number(info.update_seq);
const maxSeq = Number.parseInt(`${info.update_seq ?? 0}`, 10);
let processed = 0;
let read = 0;
let errored = 0;
@@ -560,7 +559,7 @@ Success: ${successCount}, Errored: ${errored}`;
});
docMap.set(id, set);
} else if (doc.type === EntryTypes.CHUNK) {
const id = doc._id as DocumentID;
const id = doc._id;
if (chunkMap.has(id)) {
return;
}
@@ -759,68 +758,68 @@ Success: ${successCount}, Errored: ${errored}`;
}
}
/**
* Compact the database by temporarily setting the revision limit to 1.
* @returns
*/
async compactDatabaseWithRevLimit() {
// Temporarily set revs_limit to 1, perform compaction, and restore the original revs_limit.
// Very dangerous operation, so now suppressed.
return false;
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
if (!remote) {
this._notice("Failed to connect to remote for compaction.");
return;
}
if (typeof remote == "string") {
this._notice(`Failed to connect to remote for compaction. ${remote}`);
return;
}
const customHeaders = parseHeaderValues(this.settings.couchDB_CustomHeaders);
const credential = generateCredentialObject(this.settings);
const request = async (path: string, method: string = "GET", body: any = undefined) => {
const req = await _requestToCouchDB(
this.settings.couchDB_URI.replace(/\/+$/, "") +
(this.settings.couchDB_DBNAME ? `/${this.settings.couchDB_DBNAME}` : ""),
credential,
window.origin,
path,
body,
method,
customHeaders
);
return req;
};
let revsLimit = "";
const req = await request(`_revs_limit`, "GET");
if (req.status == 200) {
revsLimit = req.text.trim();
this._info(`Remote database _revs_limit: ${revsLimit}`);
} else {
this._notice(`Failed to get remote database _revs_limit. Status: ${req.status}`);
return;
}
const req2 = await request(`_revs_limit`, "PUT", 1);
if (req2.status == 200) {
this._info(`Set remote database _revs_limit to 1 for compaction.`);
}
try {
await this.compactDatabase();
} finally {
// Restore revs_limit
if (revsLimit) {
const req3 = await request(`_revs_limit`, "PUT", parseInt(revsLimit));
if (req3.status == 200) {
this._info(`Restored remote database _revs_limit to ${revsLimit}.`);
} else {
this._notice(
`Failed to restore remote database _revs_limit. Status: ${req3.status} / ${req3.text}`
);
}
}
}
}
// /**
// * Compact the database by temporarily setting the revision limit to 1.
// * @returns
// */
// async compactDatabaseWithRevLimit() {
// // Temporarily set revs_limit to 1, perform compaction, and restore the original revs_limit.
// // Very dangerous operation, so now suppressed.
// return Promise.resolve(false);
// const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
// const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
// if (!remote) {
// this._notice("Failed to connect to remote for compaction.");
// return;
// }
// if (typeof remote == "string") {
// this._notice(`Failed to connect to remote for compaction. ${remote}`);
// return;
// }
// const customHeaders = parseHeaderValues(this.settings.couchDB_CustomHeaders);
// const credential = generateCredentialObject(this.settings);
// const request = async (path: string, method: string = "GET", body: any = undefined) => {
// const req = await _requestToCouchDB(
// this.settings.couchDB_URI.replace(/\/+$/, "") +
// (this.settings.couchDB_DBNAME ? `/${this.settings.couchDB_DBNAME}` : ""),
// credential,
// window.origin,
// path,
// body,
// method,
// customHeaders
// );
// return req;
// };
// let revsLimit = "";
// const req = await request(`_revs_limit`, "GET");
// if (req.status == 200) {
// revsLimit = req.text.trim();
// this._info(`Remote database _revs_limit: ${revsLimit}`);
// } else {
// this._notice(`Failed to get remote database _revs_limit. Status: ${req.status}`);
// return;
// }
// const req2 = await request(`_revs_limit`, "PUT", 1);
// if (req2.status == 200) {
// this._info(`Set remote database _revs_limit to 1 for compaction.`);
// }
// try {
// await this.compactDatabase();
// } finally {
// // Restore revs_limit
// if (revsLimit) {
// const req3 = await request(`_revs_limit`, "PUT", parseInt(revsLimit));
// if (req3.status == 200) {
// this._info(`Restored remote database _revs_limit to ${revsLimit}.`);
// } else {
// this._notice(
// `Failed to restore remote database _revs_limit. Status: ${req3.status} / ${req3.text}`
// );
// }
// }
// }
// }
async gcv3() {
if (!this.isAvailable()) return;
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
@@ -929,7 +928,7 @@ This may indicate that some devices have not completed synchronisation, which co
usedChunks.add(chunkId);
}
} else if (doc.type === EntryTypes.CHUNK) {
allChunks.set(doc._id as DocumentID, doc._rev);
allChunks.set(doc._id, doc._rev);
}
}
this._notice(

View File

@@ -24,6 +24,7 @@
let serverInfo = $state<P2PServerInfo | undefined>(undefined);
let replicatorStatus = $state<P2PReplicatorStatus | undefined>(undefined);
let roomSuffix = $state<string>(extractP2PRoomSuffix(core?.services.setting.currentSettings()?.P2P_roomID ?? ""));
let useDiagRTC = $state<boolean>(core?.services.setting.currentSettings()?.P2P_useDiagRTC ?? false);
async function requestServerStatus() {
await Promise.resolve(liveSyncReplicator.requestStatus());
@@ -48,6 +49,18 @@
}
}
async function toggleDiagRTC() {
if (!core) {
return;
}
const next = !useDiagRTC;
await core.services.setting.updateSettings((settings) => {
settings.P2P_useDiagRTC = next;
return settings;
}, true);
useDiagRTC = next;
}
onMount(() => {
const unsubscribe = eventHub.onEvent(EVENT_SERVER_STATUS, (status) => {
serverInfo = status;
@@ -58,6 +71,7 @@
});
const unsubscribeSettings = eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
roomSuffix = extractP2PRoomSuffix(settings?.P2P_roomID ?? "");
useDiagRTC = settings?.P2P_useDiagRTC ?? false;
});
fireAndForget(async () => {
@@ -131,6 +145,48 @@
</button>
</div>
{/if}
{#if core}
<div class="status-item status-action diag-toggle-row">
<label class="broadcast-label" for="diag-toggle">
🕵️ Diag
</label>
<button
id="diag-toggle"
class="broadcast-button {useDiagRTC ? 'is-on' : 'is-off'}"
onclick={toggleDiagRTC}
title={useDiagRTC
? 'Diagnostic RTCPeerConnection is enabled'
: 'Use Diagnostic RTCPeerConnection for statistics'}
>
{useDiagRTC ? 'On' : 'Off'}
</button>
</div>
{/if}
{#if serverInfo}
<div class="diag-section">
<h4>Stats</h4>
<div class="diag-grid">
<div class="diag-item">
<span>Incoming:</span>
<span>{serverInfo.diag.totalNewConnections}</span>
</div>
<div class="diag-item">
<span>Connected:</span>
<span>{serverInfo.diag.totalSuccessfulConnections}</span>
</div>
<div class="diag-item">
<span>Failed:</span>
<span>{serverInfo.diag.totalFailedConnections}</span>
</div>
<div class="diag-item">
<span>Closed:</span>
<span>{serverInfo.diag.totalClosedConnections}</span>
</div>
</div>
</div>
{/if}
</div>
<style>
@@ -190,6 +246,11 @@
margin-top: 0.25rem;
}
.diag-toggle-row {
align-items: center;
margin-top: 0.25rem;
}
.broadcast-label {
font-size: 0.9rem;
color: var(--text-normal);
@@ -221,4 +282,29 @@
background-color: var(--interactive-hover);
color: var(--text-normal);
}
.diag-section {
border-top: 1px solid var(--divider-color);
margin-top: 0.75rem;
padding-top: 0.75rem;
}
.diag-section h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
font-weight: 600;
}
.diag-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.35rem 0.75rem;
}
.diag-item {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
gap: 0.5rem;
}
</style>

Submodule src/lib updated: 6abcea69eb...b143cf887b

View File

@@ -12,8 +12,6 @@ import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidian
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
import { ModuleObsidianGlobalHistory } from "./modules/features/ModuleGlobalHistory.ts";
import { ModuleIntegratedTest } from "./modules/extras/ModuleIntegratedTest.ts";
import { ModuleReplicateTest } from "./modules/extras/ModuleReplicateTest.ts";
import { LocalDatabaseMaintenance } from "./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts";
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts";
import { ObsidianServiceHub } from "./modules/services/ObsidianServiceHub.ts";
@@ -156,8 +154,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
new ModuleInteractiveConflictResolver(this, core),
new ModuleObsidianGlobalHistory(this, core),
new ModuleDev(this, core),
new ModuleReplicateTest(this, core),
new ModuleIntegratedTest(this, core),
new SetupManager(core), // this should be moved to core?
new ModuleMigration(core),
];

View File

@@ -128,7 +128,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
}
async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
const mine = extractObject(TweakValuesShouldMatchedTemplate, this.settings) as TweakValues;
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") {

View File

@@ -1,9 +1,11 @@
import { ButtonComponent } from "@/deps.ts";
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "../../../deps.ts";
import { EVENT_PLUGIN_UNLOADED, eventHub } from "../../../common/events.ts";
import { compatGlobal, type CompatIntervalHandle } from "@lib/common/coreEnvFunctions.ts";
class AutoClosableModal extends Modal {
_closeByUnload() {
// eslint-disable-next-line @typescript-eslint/unbound-method
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
this.close();
}
@@ -11,9 +13,11 @@ class AutoClosableModal extends Modal {
constructor(app: App) {
super(app);
this._closeByUnload = this._closeByUnload.bind(this);
// eslint-disable-next-line @typescript-eslint/unbound-method
eventHub.once(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
}
override onClose() {
// eslint-disable-next-line @typescript-eslint/unbound-method
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
}
}
@@ -121,7 +125,7 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
this.callback = undefined;
}
override onClose(): void {
setTimeout(() => {
compatGlobal.setTimeout(() => {
if (this.callback) {
this.callback("");
this.callback = undefined;
@@ -139,7 +143,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
isManuallyClosed = false;
defaultAction: string | undefined;
timeout: number | undefined;
timer: ReturnType<typeof setInterval> | undefined = undefined;
timer: CompatIntervalHandle | undefined = undefined;
defaultButtonComponent: ButtonComponent | undefined;
wideButton: boolean;
@@ -165,12 +169,12 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
this.timeout = timeout;
this.wideButton = wideButton;
if (this.timeout) {
this.timer = setInterval(() => {
this.timer = compatGlobal.setInterval(() => {
if (this.timeout === undefined) return;
this.timeout--;
if (this.timeout < 0) {
if (this.timer) {
clearInterval(this.timer);
compatGlobal.clearInterval(this.timer);
this.defaultButtonComponent?.setButtonText(`${defaultAction}`);
this.timer = undefined;
}
@@ -213,7 +217,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
if (this.timer) {
labelWrapper.empty();
labelWrapper.style.display = "none";
clearInterval(this.timer);
compatGlobal.clearInterval(this.timer);
this.timer = undefined;
this.defaultButtonComponent?.setButtonText(`${this.defaultAction}`);
}
@@ -224,7 +228,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
this.isManuallyClosed = true;
this.result = button;
if (this.timer) {
clearInterval(this.timer);
compatGlobal.clearInterval(this.timer);
this.timer = undefined;
}
this.close();
@@ -247,7 +251,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
const { contentEl } = this;
contentEl.empty();
if (this.timer) {
clearInterval(this.timer);
compatGlobal.clearInterval(this.timer);
this.timer = undefined;
}
if (this.isManuallyClosed) {

View File

@@ -27,7 +27,6 @@ export class ObsHttpHandler extends FetchHttpHandler {
this.requestTimeoutInMs = options === undefined ? undefined : options.requestTimeout;
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
}
// eslint-disable-next-line require-await
override async handle(
request: HttpRequest,
{ abortSignal }: HttpHandlerOptions = {}

View File

@@ -13,6 +13,7 @@ import {
hiddenFilesProcessingCount,
} from "../../lib/src/mock_and_interop/stores.ts";
import type { LiveSyncCore } from "../../main.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
export class ModuleObsidianEvents extends AbstractObsidianModule {
_everyOnloadStart(): Promise<boolean> {
@@ -79,11 +80,19 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
this.watchWindowVisibility = this.watchWindowVisibility.bind(this);
this.watchWorkspaceOpen = this.watchWorkspaceOpen.bind(this);
this.watchOnline = this.watchOnline.bind(this);
// Already bound
// eslint-disable-next-line @typescript-eslint/unbound-method
this.plugin.registerEvent(this.app.workspace.on("file-open", this.watchWorkspaceOpen));
this.plugin.registerDomEvent(document, "visibilitychange", this.watchWindowVisibility);
// Already bound
// eslint-disable-next-line @typescript-eslint/unbound-method
this.plugin.registerDomEvent(activeDocument, "visibilitychange", this.watchWindowVisibility);
this.plugin.registerDomEvent(window, "focus", () => this.setHasFocus(true));
this.plugin.registerDomEvent(window, "blur", () => this.setHasFocus(false));
// Already bound
// eslint-disable-next-line @typescript-eslint/unbound-method
this.plugin.registerDomEvent(window, "online", this.watchOnline);
// Already bound
// eslint-disable-next-line @typescript-eslint/unbound-method
this.plugin.registerDomEvent(window, "offline", this.watchOnline);
}
@@ -222,9 +231,9 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
);
});
this.plugin.registerInterval(
setInterval(() => {
compatGlobal.setInterval(() => {
__tick.value++;
}, 1000) as unknown as number
}, 1000)
);
let stableCheck = 3;

View File

@@ -1,17 +1,21 @@
import { delay, fireAndForget } from "octagonal-wheels/promises";
import { __onMissingTranslation } from "../../lib/src/common/i18n";
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
import { eventHub } from "../../common/events";
import { enableTestFunction } from "./devUtil/testUtils.ts";
import { TestPaneView, VIEW_TYPE_TEST } from "./devUtil/TestPaneView.ts";
import { writable } from "svelte/store";
import type { FilePathWithPrefix } from "../../lib/src/common/types.ts";
import type { CouchDBCredentials, FilePathWithPrefix } from "../../lib/src/common/types.ts";
import type { LiveSyncCore } from "../../main.ts";
import { getConfiguredFunctionsForEncryption } from "@/lib/src/pouchdb/encryption.ts";
import { AuthorizationHeaderGenerator } from "@/lib/src/replication/httplib.ts";
import { fetchChangesForInitialSync } from "@/lib/src/pouchdb/StreamingFetch.ts";
import { PouchDB } from '@lib/pouchdb/pouchdb-browser.ts';
import { sizeToHumanReadable } from "octagonal-wheels/number";
export class ModuleDev extends AbstractObsidianModule {
_everyOnloadStart(): Promise<boolean> {
__onMissingTranslation(() => {});
__onMissingTranslation(() => { });
return Promise.resolve(true);
}
async onMissingTranslation(key: string): Promise<void> {
@@ -98,7 +102,75 @@ export class ModuleDev extends AbstractObsidianModule {
});
return Promise.resolve(true);
}
async _runBulkCopyTest() {
const settings = this.settings;
const dummyLocalDatabaseForDrop = new PouchDB("dummy-local");
await dummyLocalDatabaseForDrop.destroy();
const dummyLocalDatabase = new PouchDB("dummy-local");
const replicator = await this.core.services.replicator.getNewReplicator();
if (!replicator) {
return;
}
const salt = () => replicator.getReplicationPBKDF2Salt(settings);
const enc = getConfiguredFunctionsForEncryption(settings.passphrase,
false,
false,
salt,
settings.E2EEAlgorithm,
);
const auth = (
settings.useJWT
? {
jwtAlgorithm: settings.jwtAlgorithm,
jwtKey: settings.jwtKey,
jwtExpDuration: settings.jwtExpDuration,
jwtKid: settings.jwtKid,
jwtSub: settings.jwtSub,
type: "jwt",
}
: {
username: settings.couchDB_USER,
password: settings.couchDB_PASSWORD,
type: "basic",
}
) satisfies CouchDBCredentials;
const authHeader = await (new AuthorizationHeaderGenerator().getAuthorizationHeader(auth));
const remote =
settings.couchDB_URI.replace(/\/+$/, "") +
(settings.couchDB_DBNAME == "" ? "" : "/" + settings.couchDB_DBNAME);
//
const ret = fetchChangesForInitialSync(
dummyLocalDatabase,
remote,
authHeader,
enc.outgoing,
"0",
(progress) => {
Logger(`Initial sync progress: ${progress.totalValidFetched} / ${progress.docsToFetch}
Total bytes fetched: ${sizeToHumanReadable(progress.totalBytes)}`,
LOG_LEVEL_NOTICE, "fetch-init-progress"
);
}
);
await ret;
const allDocs = await dummyLocalDatabase.allDocs({ include_docs: false });
Logger(`Bulk copy test completed. Total documents in local database: ${allDocs.total_rows}`, LOG_LEVEL_NOTICE, "fetch-init-complete");
await dummyLocalDatabase.destroy();
Logger(`Dummy local database has been destroyed after test.`, LOG_LEVEL_NOTICE);
}
async _everyOnLayoutReady(): Promise<boolean> {
this.addCommand({
"id": "bulk-copy-test",
"name": "(DEBUG) Bulk copy test",
"callback": async () => {
await this._runBulkCopyTest();
}
})
if (!this.settings.enableDebugTools) return Promise.resolve(true);
// if (await this.core.storageAccess.isExistsIncludeHidden("_SHOWDIALOGAUTO.md")) {
// void this.core.$$showView(VIEW_TYPE_TEST);
@@ -111,7 +183,7 @@ export class ModuleDev extends AbstractObsidianModule {
const filename = "test-create-conflict.md";
const content = `# Test create conflict\n\n`;
const w = await this.core.databaseFileAccess.store({
name: filename as FilePathWithPrefix,
name: filename,
path: filename as FilePathWithPrefix,
body: new Blob([content], { type: "text/markdown" }),
stat: {

View File

@@ -1,446 +0,0 @@
import { delay } from "octagonal-wheels/promises";
import { LOG_LEVEL_NOTICE, REMOTE_MINIO, type FilePathWithPrefix } from "src/lib/src/common/types";
import { shareRunningResult } from "octagonal-wheels/concurrency/lock";
import { AbstractObsidianModule } from "../AbstractObsidianModule";
export class ModuleIntegratedTest extends AbstractObsidianModule {
async waitFor(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
await delay(100);
const start = Date.now();
while (!(await proc())) {
if (timeout > 0) {
if (Date.now() - start > timeout) {
this._log(`Timeout`);
return false;
}
}
await delay(500);
}
return true;
}
waitWithReplicating(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
return this.waitFor(async () => {
await this.tryReplicate();
return await proc();
}, timeout);
}
async storageContentIsEqual(file: string, content: string): Promise<boolean> {
try {
const fileContent = await this.readStorageContent(file as FilePathWithPrefix);
if (fileContent === content) {
return true;
} else {
// this._log(`Content is not same \n Expected:${content}\n Actual:${fileContent}`, LOG_LEVEL_VERBOSE);
return false;
}
} catch (e) {
this._log(`Error: ${e}`);
return false;
}
}
async assert(proc: () => Promise<boolean>): Promise<boolean> {
if (!(await proc())) {
this._log(`Assertion failed`);
return false;
}
return true;
}
async __orDie(key: string, proc: () => Promise<boolean>): Promise<true> | never {
if (!(await this._test(key, proc))) {
throw new Error(`${key}`);
}
return true;
}
tryReplicate() {
if (!this.settings.liveSync) {
return shareRunningResult("replicate-test", async () => {
await this.services.replication.replicate();
});
}
}
async readStorageContent(file: FilePathWithPrefix): Promise<string | undefined> {
if (!(await this.core.storageAccess.isExistsIncludeHidden(file))) {
return undefined;
}
return await this.core.storageAccess.readHiddenFileText(file);
}
async __proceed(no: number, title: string): Promise<boolean> {
const stepFile = "_STEP.md" as FilePathWithPrefix;
const stepAckFile = "_STEP_ACK.md" as FilePathWithPrefix;
const stepContent = `Step ${no}`;
await this.services.conflict.resolveByNewest(stepFile);
await this.core.storageAccess.writeFileAuto(stepFile, stepContent);
await this.__orDie(`Wait for acknowledge ${no}`, async () => {
if (
!(await this.waitWithReplicating(async () => {
return await this.storageContentIsEqual(stepAckFile, stepContent);
}, 20000))
)
return false;
return true;
});
return true;
}
async __join(no: number, title: string): Promise<boolean> {
const stepFile = "_STEP.md" as FilePathWithPrefix;
const stepAckFile = "_STEP_ACK.md" as FilePathWithPrefix;
// const otherStepFile = `_STEP_${isLeader ? "R" : "L"}.md` as FilePathWithPrefix;
const stepContent = `Step ${no}`;
await this.__orDie(`Wait for step ${no} (${title})`, async () => {
if (
!(await this.waitWithReplicating(async () => {
return await this.storageContentIsEqual(stepFile, stepContent);
}, 20000))
)
return false;
return true;
});
await this.services.conflict.resolveByNewest(stepAckFile);
await this.core.storageAccess.writeFileAuto(stepAckFile, stepContent);
await this.tryReplicate();
return true;
}
async performStep({
step,
title,
isGameChanger,
proc,
check,
}: {
step: number;
title: string;
isGameChanger: boolean;
proc: () => Promise<any>;
check: () => Promise<boolean>;
}): Promise<boolean> {
if (isGameChanger) {
await this.__proceed(step, title);
try {
await proc();
} catch (e) {
this._log(`Error: ${e}`);
return false;
}
return await this.__orDie(`Step ${step} - ${title}`, async () => await this.waitWithReplicating(check));
} else {
return await this.__join(step, title);
}
}
// // see scenario.md
// async testLeader(testMain: (testFileName: FilePathWithPrefix) => Promise<boolean>): Promise<boolean> {
// }
// async testReceiver(testMain: (testFileName: FilePathWithPrefix) => Promise<boolean>): Promise<boolean> {
// }
async nonLiveTestRunner(
isLeader: boolean,
testMain: (testFileName: FilePathWithPrefix, isLeader: boolean) => Promise<boolean>
): Promise<boolean> {
const storage = this.core.storageAccess;
// const database = this.core.databaseFileAccess;
// const _orDie = this._orDie.bind(this);
const testCommandFile = "IT.md" as FilePathWithPrefix;
const textCommandResponseFile = "ITx.md" as FilePathWithPrefix;
let testFileName: FilePathWithPrefix;
this.addTestResult(
"-------Starting ... ",
true,
`Test as ${isLeader ? "Leader" : "Receiver"} command file ${testCommandFile}`
);
if (isLeader) {
await this.__proceed(0, "start");
}
await this.tryReplicate();
await this.performStep({
step: 0,
title: "Make sure that command File Not Exists",
isGameChanger: isLeader,
proc: async () => await storage.removeHidden(testCommandFile),
check: async () => !(await storage.isExistsIncludeHidden(testCommandFile)),
});
await this.performStep({
step: 1,
title: "Make sure that command File Not Exists On Receiver",
isGameChanger: !isLeader,
proc: async () => await storage.removeHidden(textCommandResponseFile),
check: async () => !(await storage.isExistsIncludeHidden(textCommandResponseFile)),
});
await this.performStep({
step: 2,
title: "Decide the test file name",
isGameChanger: isLeader,
proc: async () => {
testFileName = (Date.now() + "-" + Math.ceil(Math.random() * 1000) + ".md") as FilePathWithPrefix;
const testCommandFile = "IT.md" as FilePathWithPrefix;
await storage.writeFileAuto(testCommandFile, testFileName);
},
check: () => Promise.resolve(true),
});
await this.performStep({
step: 3,
title: "Wait for the command file to be arrived",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => await storage.isExistsIncludeHidden(testCommandFile),
});
await this.performStep({
step: 4,
title: "Send the response file",
isGameChanger: !isLeader,
proc: async () => {
await storage.writeHiddenFileAuto(textCommandResponseFile, "!");
},
check: () => Promise.resolve(true),
});
await this.performStep({
step: 5,
title: "Wait for the response file to be arrived",
isGameChanger: isLeader,
proc: async () => {},
check: async () => await storage.isExistsIncludeHidden(textCommandResponseFile),
});
await this.performStep({
step: 6,
title: "Proceed to begin the test",
isGameChanger: isLeader,
proc: async () => {},
check: () => Promise.resolve(true),
});
await this.performStep({
step: 6,
title: "Begin the test",
isGameChanger: !false,
proc: async () => {},
check: () => {
return Promise.resolve(true);
},
});
// await this.step(0, isLeader, true);
try {
this.addTestResult("** Main------", true, ``);
if (isLeader) {
return await testMain(testFileName!, true);
} else {
const testFileName = await this.readStorageContent(testCommandFile);
this.addTestResult("testFileName", true, `Request client to use :${testFileName!}`);
return await testMain(testFileName! as FilePathWithPrefix, false);
}
} finally {
this.addTestResult("Teardown", true, `Deleting ${testFileName!}`);
await storage.removeHidden(testFileName!);
}
return true;
// Make sure the
}
async testBasic(filename: FilePathWithPrefix, isLeader: boolean): Promise<boolean> {
const storage = this.core.storageAccess;
const database = this.core.databaseFileAccess;
await this.addTestResult(
`---**Starting Basic Test**---`,
true,
`Test as ${isLeader ? "Leader" : "Receiver"} command file ${filename}`
);
// if (isLeader) {
// await this._proceed(0);
// }
// await this.tryReplicate();
await this.performStep({
step: 0,
title: "Make sure that file is not exist",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => !(await storage.isExists(filename)),
});
await this.performStep({
step: 1,
title: "Write a file",
isGameChanger: isLeader,
proc: async () => await storage.writeFileAuto(filename, "Hello World"),
check: async () => await storage.isExists(filename),
});
await this.performStep({
step: 2,
title: "Make sure the file is arrived",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => await storage.isExists(filename),
});
await this.performStep({
step: 3,
title: "Update to Hello World 2",
isGameChanger: isLeader,
proc: async () => await storage.writeFileAuto(filename, "Hello World 2"),
check: async () => await this.storageContentIsEqual(filename, "Hello World 2"),
});
await this.performStep({
step: 4,
title: "Make sure the modified file is arrived",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => await this.storageContentIsEqual(filename, "Hello World 2"),
});
await this.performStep({
step: 5,
title: "Update to Hello World 3",
isGameChanger: !isLeader,
proc: async () => await storage.writeFileAuto(filename, "Hello World 3"),
check: async () => await this.storageContentIsEqual(filename, "Hello World 3"),
});
await this.performStep({
step: 6,
title: "Make sure the modified file is arrived",
isGameChanger: isLeader,
proc: async () => {},
check: async () => await this.storageContentIsEqual(filename, "Hello World 3"),
});
const multiLineContent = `Line1:A
Line2:B
Line3:C
Line4:D`;
await this.performStep({
step: 7,
title: "Update to Multiline",
isGameChanger: isLeader,
proc: async () => await storage.writeFileAuto(filename, multiLineContent),
check: async () => await this.storageContentIsEqual(filename, multiLineContent),
});
await this.performStep({
step: 8,
title: "Make sure the modified file is arrived",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => await this.storageContentIsEqual(filename, multiLineContent),
});
// While LiveSync, possibly cannot cause the conflict.
if (!this.settings.liveSync) {
// Step 9 Make Conflict But Resolvable
const multiLineContentL = `Line1:A
Line2:B
Line3:C!
Line4:D`;
const multiLineContentC = `Line1:A
Line2:bbbbb
Line3:C
Line4:D`;
await this.performStep({
step: 9,
title: "Progress to be conflicted",
isGameChanger: isLeader,
proc: async () => {},
check: () => Promise.resolve(true),
});
await storage.writeFileAuto(filename, isLeader ? multiLineContentL : multiLineContentC);
await this.performStep({
step: 10,
title: "Update As Conflicted",
isGameChanger: !isLeader,
proc: async () => {},
check: () => Promise.resolve(true),
});
await this.performStep({
step: 10,
title: "Make sure Automatically resolved",
isGameChanger: isLeader,
proc: async () => {},
check: async () => (await database.getConflictedRevs(filename)).length === 0,
});
await this.performStep({
step: 11,
title: "Make sure Automatically resolved",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => (await database.getConflictedRevs(filename)).length === 0,
});
const sensiblyMergedContent = `Line1:A
Line2:bbbbb
Line3:C!
Line4:D`;
await this.performStep({
step: 12,
title: "Make sure Sensibly Merged on Leader",
isGameChanger: isLeader,
proc: async () => {},
check: async () => await this.storageContentIsEqual(filename, sensiblyMergedContent),
});
await this.performStep({
step: 13,
title: "Make sure Sensibly Merged on Receiver",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => await this.storageContentIsEqual(filename, sensiblyMergedContent),
});
}
await this.performStep({
step: 14,
title: "Delete File",
isGameChanger: isLeader,
proc: async () => {
await storage.removeHidden(filename);
},
check: async () => !(await storage.isExists(filename)),
});
await this.performStep({
step: 15,
title: "Make sure File is deleted",
isGameChanger: !isLeader,
proc: async () => {},
check: async () => !(await storage.isExists(filename)),
});
this._log(`The Basic Test has been completed`, LOG_LEVEL_NOTICE);
return true;
}
async testBasicEvent(isLeader: boolean) {
this.settings.liveSync = false;
await this.saveSettings();
await this._test("basic", async () => await this.nonLiveTestRunner(isLeader, (t, l) => this.testBasic(t, l)));
}
async testBasicLive(isLeader: boolean) {
this.settings.liveSync = true;
await this.saveSettings();
await this._test("basic", async () => await this.nonLiveTestRunner(isLeader, (t, l) => this.testBasic(t, l)));
}
async _everyModuleTestMultiDevice(): Promise<boolean> {
if (!this.settings.enableDebugTools) return Promise.resolve(true);
const isLeader = this.core.services.vault.vaultName().indexOf("recv") === -1;
this.addTestResult("-------", true, `Test as ${isLeader ? "Leader" : "Receiver"}`);
try {
this._log(`Starting Test`);
await this.testBasicEvent(isLeader);
if (this.settings.remoteType == REMOTE_MINIO) await this.testBasicLive(isLeader);
} catch (e) {
this._log(e);
this._log(`Error: ${e}`);
return Promise.resolve(false);
}
return Promise.resolve(true);
}
override onBindFunction(core: typeof this.core, services: typeof core.services): void {
services.test.testMultiDevice.addHandler(this._everyModuleTestMultiDevice.bind(this));
}
}

View File

@@ -1,590 +0,0 @@
// I intend to discontinue maintenance of this class. It seems preferable to test it externally.
import { delay } from "octagonal-wheels/promises";
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
import { eventHub } from "../../common/events";
import { getWebCrypto } from "../../lib/src/mods.ts";
import { uint8ArrayToHexString } from "octagonal-wheels/binary/hex";
import { parseYaml, requestUrl, stringifyYaml } from "@/deps.ts";
import type { FilePath } from "../../lib/src/common/types.ts";
import { scheduleTask } from "octagonal-wheels/concurrency/task";
import { getFileRegExp } from "../../lib/src/common/utils.ts";
import type { LiveSyncCore } from "../../main.ts";
declare global {
interface LSEvents {
"debug-sync-status": string[];
}
}
export class ModuleReplicateTest extends AbstractObsidianModule {
testRootPath = "_test/";
testInfoPath = "_testinfo/";
get isLeader() {
return (
this.services.vault.getVaultName().indexOf("dev") >= 0 &&
this.services.vault.vaultName().indexOf("recv") < 0
);
}
get nameByKind() {
if (!this.isLeader) {
return "RECV";
} else if (this.isLeader) {
return "LEADER";
}
}
get pairName() {
if (this.isLeader) {
return "RECV";
} else if (!this.isLeader) {
return "LEADER";
}
}
watchIsSynchronised = false;
statusBarSyncStatus?: HTMLElement;
async readFileContent(file: string) {
try {
return await this.core.storageAccess.readHiddenFileText(file);
} catch {
return "";
}
}
async dumpList() {
if (this.settings.syncInternalFiles) {
this._log("Write file list (Include Hidden)");
await this.__dumpFileListIncludeHidden("files.md");
} else {
this._log("Write file list");
await this.__dumpFileList("files.md");
}
}
async _everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
if (!this.settings.enableDebugTools) return Promise.resolve(true);
await this.dumpList();
return true;
}
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
if (!this.settings.enableDebugTools) return Promise.resolve(true);
this.addCommand({
id: "dump-file-structure-normal",
name: `Dump Structure (Normal)`,
callback: () => {
void this.__dumpFileList("files.md").finally(() => {
void this.refreshSyncStatus();
});
},
});
this.addCommand({
id: "dump-file-structure-ih",
name: "Dump Structure (Include Hidden)",
callback: () => {
const d = "files.md";
void this.__dumpFileListIncludeHidden(d);
},
});
this.addCommand({
id: "dump-file-structure-auto",
name: "Dump Structure",
callback: () => {
void this.dumpList();
},
});
this.addCommand({
id: "dump-file-test",
name: `Perform Test (Dev) ${this.isLeader ? "(Leader)" : "(Recv)"}`,
callback: () => {
void this.performTestManually();
},
});
this.addCommand({
id: "watch-sync-result",
name: `Watch sync result is matched between devices`,
callback: () => {
this.watchIsSynchronised = !this.watchIsSynchronised;
void this.refreshSyncStatus();
},
});
this.app.vault.on("modify", async (file) => {
if (file.path.startsWith(this.testInfoPath)) {
await this.refreshSyncStatus();
} else {
scheduleTask("dumpStatus", 125, async () => {
await this.dumpList();
return true;
});
}
});
this.statusBarSyncStatus = this.plugin.addStatusBarItem();
return Promise.resolve(true);
}
async getSyncStatusAsText() {
const fileMine = this.testInfoPath + this.nameByKind + "/" + "files.md";
const filePair = this.testInfoPath + this.pairName + "/" + "files.md";
const mine = parseYaml(await this.readFileContent(fileMine));
const pair = parseYaml(await this.readFileContent(filePair));
const result = [] as string[];
if (mine.length != pair.length) {
result.push(`File count is different: ${mine.length} vs ${pair.length}`);
}
const filesAll = new Set([...mine.map((e: any) => e.path), ...pair.map((e: any) => e.path)]);
for (const file of filesAll) {
const mineFile = mine.find((e: any) => e.path == file);
const pairFile = pair.find((e: any) => e.path == file);
if (!mineFile || !pairFile) {
result.push(`File not found: ${file}`);
} else {
if (mineFile.size != pairFile.size) {
result.push(`Size is different: ${file} ${mineFile.size} vs ${pairFile.size}`);
}
if (mineFile.hash != pairFile.hash) {
result.push(`Hash is different: ${file} ${mineFile.hash} vs ${pairFile.hash}`);
}
}
}
eventHub.emitEvent("debug-sync-status", result);
return result.join("\n");
}
async refreshSyncStatus() {
if (this.watchIsSynchronised) {
// Normal Files
const syncStatus = await this.getSyncStatusAsText();
if (syncStatus) {
this.statusBarSyncStatus!.setText(`Sync Status: Having Error`);
this._log(`Sync Status: Having Error\n${syncStatus}`, LOG_LEVEL_INFO);
} else {
this.statusBarSyncStatus!.setText(`Sync Status: Synchronised`);
}
} else {
this.statusBarSyncStatus!.setText("");
}
}
async __dumpFileList(outFile?: string) {
if (!this.core || !this.core.storageAccess) {
this._log("No storage access", LOG_LEVEL_INFO);
return;
}
const files = await this.core.storageAccess.getFiles();
const out = [] as any[];
const webcrypto = await getWebCrypto();
for (const file of files) {
if (!(await this.services.vault.isTargetFile(file.path))) {
continue;
}
if (file.path.startsWith(this.testInfoPath)) continue;
const stat = await this.core.storageAccess.stat(file.path);
if (stat) {
const hashSrc = await this.core.storageAccess.readHiddenFileBinary(file.path);
const hash = await webcrypto.subtle.digest("SHA-1", hashSrc);
const hashStr = uint8ArrayToHexString(new Uint8Array(hash));
const item = {
path: file.path,
name: file.name,
size: stat.size,
mtime: stat.mtime,
hash: hashStr,
};
// const fileLine = `-${file.path}:${stat.size}:${stat.mtime}:${hashStr}`;
out.push(item);
}
}
out.sort((a, b) => a.path.localeCompare(b.path));
if (outFile) {
outFile = this.testInfoPath + this.nameByKind + "/" + outFile;
await this.core.storageAccess.ensureDir(outFile);
await this.core.storageAccess.writeHiddenFileAuto(outFile, stringifyYaml(out));
} else {
// console.dir(out);
}
this._log(`Dumped ${out.length} files`, LOG_LEVEL_INFO);
}
async __dumpFileListIncludeHidden(outFile?: string) {
const ignorePatterns = getFileRegExp(this.core.settings, "syncInternalFilesIgnorePatterns");
const targetPatterns = getFileRegExp(this.core.settings, "syncInternalFilesTargetPatterns");
const out = [] as any[];
const files = await this.core.storageAccess.getFilesIncludeHidden("", targetPatterns, ignorePatterns);
// console.dir(files);
const webcrypto = await getWebCrypto();
for (const file of files) {
// if (!await this.core.$$isTargetFile(file)) {
// continue;
// }
if (file.startsWith(this.testInfoPath)) continue;
const stat = await this.core.storageAccess.statHidden(file);
if (stat) {
const hashSrc = await this.core.storageAccess.readHiddenFileBinary(file);
const hash = await webcrypto.subtle.digest("SHA-1", hashSrc);
const hashStr = uint8ArrayToHexString(new Uint8Array(hash));
const item = {
path: file,
name: file.split("/").pop(),
size: stat.size,
mtime: stat.mtime,
hash: hashStr,
};
// const fileLine = `-${file.path}:${stat.size}:${stat.mtime}:${hashStr}`;
out.push(item);
}
}
out.sort((a, b) => a.path.localeCompare(b.path));
if (outFile) {
outFile = this.testInfoPath + this.nameByKind + "/" + outFile;
await this.core.storageAccess.ensureDir(outFile);
await this.core.storageAccess.writeHiddenFileAuto(outFile, stringifyYaml(out));
} else {
// console.dir(out);
}
this._log(`Dumped ${out.length} files`, LOG_LEVEL_NOTICE);
}
async collectTestFiles() {
const remoteTopDir = "https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/refs/heads/main/";
const files = [
"README.md",
"docs/adding_translations.md",
"docs/design_docs_of_journalsync.md",
"docs/design_docs_of_keep_newborn_chunks.md",
"docs/design_docs_of_prefixed_hidden_file_sync.md",
"docs/design_docs_of_sharing_tweak_value.md",
"docs/quick_setup_cn.md",
"docs/quick_setup_ja.md",
"docs/quick_setup.md",
"docs/settings_ja.md",
"docs/settings.md",
"docs/setup_cloudant_ja.md",
"docs/setup_cloudant.md",
"docs/setup_flyio.md",
"docs/setup_own_server_cn.md",
"docs/setup_own_server_ja.md",
"docs/setup_own_server.md",
"docs/tech_info_ja.md",
"docs/tech_info.md",
"docs/terms.md",
"docs/troubleshooting.md",
"images/1.png",
"images/2.png",
"images/corrupted_data.png",
"images/hatch.png",
"images/lock_pattern1.png",
"images/lock_pattern2.png",
"images/quick_setup_1.png",
"images/quick_setup_2.png",
"images/quick_setup_3.png",
"images/quick_setup_3b.png",
"images/quick_setup_4.png",
"images/quick_setup_5.png",
"images/quick_setup_6.png",
"images/quick_setup_7.png",
"images/quick_setup_8.png",
"images/quick_setup_9_1.png",
"images/quick_setup_9_2.png",
"images/quick_setup_10.png",
"images/remote_db_setting.png",
"images/write_logs_into_the_file.png",
];
for (const file of files) {
const remote = remoteTopDir + file;
const local = this.testRootPath + file;
try {
const f = (await requestUrl(remote)).arrayBuffer;
await this.core.storageAccess.ensureDir(local);
await this.core.storageAccess.writeHiddenFileAuto(local, f);
} catch (ex) {
this._log(`Could not fetch ${remote}`, LOG_LEVEL_VERBOSE);
this._log(ex, LOG_LEVEL_VERBOSE);
}
}
await this.dumpList();
}
async waitFor(proc: () => Promise<boolean>, timeout = 10000): Promise<boolean> {
await delay(100);
const start = Date.now();
while (!(await proc())) {
if (timeout > 0) {
if (Date.now() - start > timeout) {
this._log(`Timeout`);
return false;
}
}
await delay(500);
}
return true;
}
async testConflictedManually1() {
await this.services.replication.replicate();
const commonFile = `Resolve!
*****, the amazing chocolatier!!`;
if (this.isLeader) {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", commonFile);
}
await this.services.replication.replicate();
await this.services.replication.replicate();
if (
(await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 1?", {
timeout: 30,
defaultOption: "Yes",
})) == "no"
) {
return;
}
const fileA = `Resolve to KEEP THIS
Willy Wonka, Willy Wonka, the amazing chocolatier!!`;
const fileB = `Resolve to DISCARD THIS
Charlie Bucket, Charlie Bucket, the amazing chocolatier!!`;
if (this.isLeader) {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", fileA);
} else {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "wonka.md", fileB);
}
if (
(await this.core.confirm.askYesNoDialog("Ready to check the result of Manually 1?", {
timeout: 30,
defaultOption: "Yes",
})) == "no"
) {
return;
}
await this.services.replication.replicate();
await this.services.replication.replicate();
if (
!(await this.waitFor(async () => {
await this.services.replication.replicate();
return (
(await this.__assertStorageContent(
(this.testRootPath + "wonka.md") as FilePath,
fileA,
false,
true
)) == true
);
}, 30000))
) {
return await this.__assertStorageContent((this.testRootPath + "wonka.md") as FilePath, fileA, false, true);
}
return true;
// We have to check the result
}
async testConflictedManually2() {
await this.services.replication.replicate();
const commonFile = `Resolve To concatenate
ABCDEFG`;
if (this.isLeader) {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", commonFile);
}
await this.services.replication.replicate();
await this.services.replication.replicate();
if (
(await this.core.confirm.askYesNoDialog("Ready to begin the test conflict Manually 2?", {
timeout: 30,
defaultOption: "Yes",
})) == "no"
) {
return;
}
const fileA = `Resolve to Concatenate
ABCDEFGHIJKLMNOPQRSTYZ`;
const fileB = `Resolve to Concatenate
AJKLMNOPQRSTUVWXYZ`;
const concatenated = `Resolve to Concatenate
ABCDEFGHIJKLMNOPQRSTUVWXYZ`;
if (this.isLeader) {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", fileA);
} else {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "concat.md", fileB);
}
if (
(await this.core.confirm.askYesNoDialog("Ready to test conflict Manually 2?", {
timeout: 30,
defaultOption: "Yes",
})) == "no"
) {
return;
}
await this.services.replication.replicate();
await this.services.replication.replicate();
if (
!(await this.waitFor(async () => {
await this.services.replication.replicate();
return (
(await this.__assertStorageContent(
(this.testRootPath + "concat.md") as FilePath,
concatenated,
false,
true
)) == true
);
}, 30000))
) {
return await this.__assertStorageContent(
(this.testRootPath + "concat.md") as FilePath,
concatenated,
false,
true
);
}
return true;
}
async testConflictAutomatic() {
if (this.isLeader) {
const baseDoc = `Tasks!
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
- [ ] Task 4
`;
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", baseDoc);
}
await delay(100);
await this.services.replication.replicate();
await this.services.replication.replicate();
if (
(await this.core.confirm.askYesNoDialog("Ready to test conflict?", {
timeout: 30,
defaultOption: "Yes",
})) == "no"
) {
return;
}
const mod1Doc = `Tasks!
- [ ] Task 1
- [v] Task 2
- [ ] Task 3
- [ ] Task 4
`;
const mod2Doc = `Tasks!
- [ ] Task 1
- [ ] Task 2
- [v] Task 3
- [ ] Task 4
`;
if (this.isLeader) {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", mod1Doc);
} else {
await this.core.storageAccess.writeHiddenFileAuto(this.testRootPath + "task.md", mod2Doc);
}
await this.services.replication.replicate();
await this.services.replication.replicate();
await delay(1000);
if (
(await this.core.confirm.askYesNoDialog("Ready to check result?", { timeout: 30, defaultOption: "Yes" })) ==
"no"
) {
return;
}
await this.services.replication.replicate();
await this.services.replication.replicate();
const mergedDoc = `Tasks!
- [ ] Task 1
- [v] Task 2
- [v] Task 3
- [ ] Task 4
`;
return this.__assertStorageContent((this.testRootPath + "task.md") as FilePath, mergedDoc, false, true);
}
// No longer tested
async checkConflictResolution() {
this._log("Before testing conflicted files, resolve all once", LOG_LEVEL_NOTICE);
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
await this.services.replication.replicate();
await delay(1000);
if (!(await this.testConflictAutomatic())) {
this._log("Conflict resolution (Auto) failed", LOG_LEVEL_NOTICE);
return false;
}
if (!(await this.testConflictedManually1())) {
this._log("Conflict resolution (Manual1) failed", LOG_LEVEL_NOTICE);
return false;
}
if (!(await this.testConflictedManually2())) {
this._log("Conflict resolution (Manual2) failed", LOG_LEVEL_NOTICE);
return false;
}
return true;
}
async __assertStorageContent(
fileName: FilePath,
content: string,
inverted = false,
showResult = false
): Promise<boolean | string> {
try {
const fileContent = await this.core.storageAccess.readHiddenFileText(fileName);
let result = fileContent === content;
if (inverted) {
result = !result;
}
if (result) {
return true;
} else {
if (showResult) {
this._log(`Content is not same \n Expected:${content}\n Actual:${fileContent}`, LOG_LEVEL_VERBOSE);
}
return `Content is not same \n Expected:${content}\n Actual:${fileContent}`;
}
} catch (e) {
this._log(`Cannot assert storage content: ${e}`);
return false;
}
}
async performTestManually() {
if (!this.settings.enableDebugTools) return Promise.resolve(true);
await this.checkConflictResolution();
// await this.collectTestFiles();
}
// testResults = writable<[boolean, string, string][]>([]);
// testResults: string[] = [];
// $$addTestResult(name: string, key: string, result: boolean, summary?: string, message?: string): void {
// const logLine = `${name}: ${key} ${summary ?? ""}`;
// this.testResults.update((results) => {
// results.push([result, logLine, message ?? ""]);
// return results;
// });
// }
private async _everyModuleTestMultiDevice(): Promise<boolean> {
if (!this.settings.enableDebugTools) return Promise.resolve(true);
// this.core.$$addTestResult("DevModule", "Test", true);
// return Promise.resolve(true);
await this._test("Conflict resolution", async () => await this.checkConflictResolution());
return this.testDone();
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
services.test.testMultiDevice.addHandler(this._everyModuleTestMultiDevice.bind(this));
}
}

View File

@@ -1,17 +1,15 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onMount } from "svelte";
import type ObsidianLiveSyncPlugin from "../../../main.ts";
import { perf_trench } from "./tests.ts";
import { MarkdownRenderer, Notice } from "../../../deps.ts";
import type { ModuleDev } from "../ModuleDev.ts";
import { fireAndForget } from "octagonal-wheels/promises";
import { EVENT_LAYOUT_READY, eventHub } from "../../../common/events.ts";
import { writable } from "svelte/store";
export let plugin: ObsidianLiveSyncPlugin;
export let moduleDev: ModuleDev;
$: core = plugin.core;
let performanceTestResult = "";
let functionCheckResult = "";
let testRunning = false;
let prefTestResultEl: HTMLDivElement;
let isReady = false;

View File

@@ -3,6 +3,11 @@ import TestPaneComponent from "./TestPane.svelte";
import type ObsidianLiveSyncPlugin from "../../../main.ts";
import type { ModuleDev } from "../ModuleDev.ts";
export const VIEW_TYPE_TEST = "ols-pane-test";
declare global {
interface LSEvents {
"debug-sync-status": string[];
}
}
//Log view
export class TestPaneView extends ItemView {
component?: TestPaneComponent;

View File

@@ -16,6 +16,7 @@ import { fireAndForget, getDocData, readContent } from "../../../lib/src/common/
import { isPlainText, stripPrefix } from "../../../lib/src/string_and_binary/path.ts";
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
function isImage(path: string) {
const ext = path.split(".").splice(-1)[0].toLowerCase();
@@ -56,6 +57,7 @@ export class DocumentHistoryModal extends Modal {
info!: HTMLDivElement;
fileInfo!: HTMLDivElement;
showDiff = false;
diffOnly = false;
id?: DocumentID;
file: FilePathWithPrefix;
@@ -70,6 +72,15 @@ export class DocumentHistoryModal extends Modal {
currentDiffIndex = -1;
diffNavContainer!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
diffOnlyLabel!: HTMLLabelElement;
// Search state
searchKeyword = "";
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
currentSearchIndex = -1;
searchResultIndicator!: HTMLSpanElement;
searchProgressIndicator!: HTMLSpanElement;
searchTimeout: number | null = null;
constructor(
app: App,
@@ -88,9 +99,12 @@ export class DocumentHistoryModal extends Modal {
if (!file && id) {
this.file = this.services.path.id2path(id);
}
if (localStorage.getItem("ols-history-highlightdiff") == "1") {
if (this.app.loadLocalStorage("ols-history-highlightdiff") == "1") {
this.showDiff = true;
}
if (this.app.loadLocalStorage("ols-history-diffonly") == "1") {
this.diffOnly = true;
}
}
async loadFile(initialRev?: string) {
@@ -151,17 +165,48 @@ export class DocumentHistoryModal extends Modal {
}
appendTextDiff(diff: [number, string][]) {
let hasOmitted = false;
for (const [operation, text] of diff) {
if (operation == DIFF_DELETE) {
this.contentView.createSpan({ text, cls: "history-deleted" });
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-deleted" }), text);
hasOmitted = false;
} else if (operation == DIFF_EQUAL) {
this.contentView.createSpan({ text, cls: "history-normal" });
if (this.diffOnly) {
if (!hasOmitted) {
this.contentView.appendText("\n...\n");
hasOmitted = true;
}
} else {
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-normal" }), text);
}
} else if (operation == DIFF_INSERT) {
this.contentView.createSpan({ text, cls: "history-added" });
this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-added" }), text);
hasOmitted = false;
}
}
}
appendSearchHighlightedText(container: HTMLElement, text: string) {
if (!this.searchKeyword) {
container.appendText(text);
return;
}
const escapedKeyword = this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedKeyword, "gi");
let lastIndex = 0;
for (const match of text.matchAll(regex)) {
const index = match.index ?? 0;
if (index > lastIndex) {
container.appendText(text.slice(lastIndex, index));
}
container.createEl("mark", { text: match[0] });
lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
container.appendText(text.slice(lastIndex));
}
}
appendImageDiff(baseSrc: string, overlaySrc?: string) {
const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" });
const overlay = wrap.createDiv({ cls: "overlay" });
@@ -258,7 +303,7 @@ export class DocumentHistoryModal extends Modal {
if (this.currentDeleted) {
this.appendDeletedNotice();
}
this.contentView.appendText(w1data);
this.appendSearchHighlightedText(this.contentView, w1data);
}
}
}
@@ -266,6 +311,11 @@ export class DocumentHistoryModal extends Modal {
this.resetDiffNavigation();
if (this.showDiff) {
this.navigateDiff("next");
} else if (this.searchKeyword) {
const firstMark = this.contentView.querySelector("mark");
if (firstMark) {
firstMark.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}
@@ -293,7 +343,7 @@ export class DocumentHistoryModal extends Modal {
target.classList.add("diff-focused");
target.scrollIntoView({ behavior: "smooth", block: "center" });
this.diffNavIndicator.textContent = `${this.currentDiffIndex + 1}/${diffElements.length}`;
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
}
/**
@@ -304,9 +354,9 @@ export class DocumentHistoryModal extends Modal {
if (this.diffNavIndicator) {
if (this.showDiff) {
const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted");
this.diffNavIndicator.textContent = diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014";
this.diffNavIndicator.setText(diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014");
} else {
this.diffNavIndicator.textContent = "\u2014";
this.diffNavIndicator.setText("\u2014");
}
}
this.updateDiffNavVisibility();
@@ -319,6 +369,117 @@ export class DocumentHistoryModal extends Modal {
if (this.diffNavContainer) {
this.diffNavContainer.style.display = this.showDiff ? "flex" : "none";
}
if (this.diffOnlyLabel) {
this.diffOnlyLabel.style.display = this.showDiff ? "inline-block" : "none";
}
}
/**
* Search through the last 100 revisions for the given keyword.
*/
async performSearch(keyword: string) {
this.searchKeyword = keyword;
this.searchResults = [];
this.currentSearchIndex = -1;
if (!keyword) {
this.searchResultIndicator.setText("");
this.searchProgressIndicator.setText("");
return;
}
const db = this.core.localDatabase;
const limit = 100;
const totalRevs = this.revs_info.length;
const end = Math.min(totalRevs, limit);
this.searchProgressIndicator.setText("Searching...");
const dmp = new diff_match_patch();
// 0 is the newest, higher index is older.
for (let i = 0; i < end; i++) {
const revInfo = this.revs_info[i];
const rev = revInfo.rev;
this.searchProgressIndicator.setText(`Searching ${i + 1}/${end}...`);
const doc = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
if (doc === false) continue;
const content = readDocument(doc);
if (typeof content !== "string") continue;
const keywordLower = keyword.toLocaleLowerCase();
// Search in content
if (content.toLocaleLowerCase().includes(keywordLower)) {
this.searchResults.push({ rev, index: i, matchType: "Content" });
this.updateSearchUI();
continue;
}
// Search in diff (from older version to this version)
// Older version is at i + 1
if (i < totalRevs - 1) {
const olderRev = this.revs_info[i + 1].rev;
const olderDoc = await db.getDBEntry(this.file, { rev: olderRev }, false, false, true);
if (olderDoc !== false) {
const olderContent = readDocument(olderDoc);
if (typeof olderContent === "string") {
const diffs = dmp.diff_main(olderContent, content);
let foundInDiff = false;
for (const d of diffs) {
if (
(d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) &&
d[1].toLocaleLowerCase().includes(keywordLower)
) {
foundInDiff = true;
break;
}
}
if (foundInDiff) {
this.searchResults.push({ rev, index: i, matchType: "Diff" });
this.updateSearchUI();
}
}
}
}
}
this.searchProgressIndicator.setText("Done");
this.updateSearchUI();
}
updateSearchUI() {
if (this.searchResults.length === 0) {
this.searchResultIndicator.setText(this.searchKeyword ? "No matches found" : "");
} else {
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
}
}
navigateSearch(direction: "prev" | "next") {
if (this.searchResults.length === 0) return;
if (direction === "next") {
this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length;
} else {
this.currentSearchIndex =
this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1;
}
const match = this.searchResults[this.currentSearchIndex];
this.range.value = `${this.revs_info.length - 1 - match.index}`;
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
this.updateSearchUI();
// If it's a diff match, make sure Highlight diff is on
if (match.matchType === "Diff" && !this.showDiff) {
// We could auto-enable it, but maybe just notify the user?
// For now, let's just let the user toggle it if they want to see the diff.
}
}
override onOpen() {
@@ -327,6 +488,42 @@ export class DocumentHistoryModal extends Modal {
contentEl.empty();
this.fileInfo = contentEl.createDiv("");
this.fileInfo.addClass("op-info");
// Search Row
const searchRow = contentEl.createDiv("");
searchRow.addClass("op-info");
searchRow.addClass("search-row");
searchRow.addClass("history-search-row");
const searchInput = searchRow.createEl("input", {
type: "text",
placeholder: "Search in history (last 100)...",
});
searchInput.addClass("history-search-input");
searchInput.addEventListener("input", () => {
if (this.searchTimeout) {
compatGlobal.clearTimeout(this.searchTimeout);
}
this.searchTimeout = compatGlobal.setTimeout(() => {
void this.performSearch(searchInput.value);
}, 500);
});
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
e.title = "Previous match";
e.addEventListener("click", () => this.navigateSearch("prev"));
});
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
e.title = "Next match";
e.addEventListener("click", () => this.navigateSearch("next"));
});
this.searchResultIndicator = searchRow.createEl("span", { text: "" });
this.searchResultIndicator.addClass("history-search-result-indicator");
this.searchProgressIndicator = searchRow.createEl("span", { text: "" });
this.searchProgressIndicator.addClass("history-search-progress-indicator");
const divView = contentEl.createDiv("");
divView.addClass("op-flex");
@@ -342,24 +539,43 @@ export class DocumentHistoryModal extends Modal {
const diffOptionsRow = contentEl.createDiv("");
diffOptionsRow.addClass("op-info");
diffOptionsRow.addClass("diff-options-row");
diffOptionsRow.addClass("history-diff-options-row");
diffOptionsRow.createEl("label", {}, (label) => {
label.appendChild(
createEl("input", { type: "checkbox" }, (checkbox) => {
if (this.showDiff) {
checkbox.checked = true;
}
checkbox.addEventListener("input", (evt: any) => {
this.showDiff = checkbox.checked;
localStorage.setItem("ols-history-highlightdiff", this.showDiff == true ? "1" : "");
this.updateDiffNavVisibility();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
})
);
const highlightDiffContainer = diffOptionsRow.createDiv("");
highlightDiffContainer.addClass("history-highlight-diff-container");
highlightDiffContainer.createEl("label", {}, (label) => {
label.addClass("history-highlight-diff-label");
label.createEl("input", { type: "checkbox" }, (checkbox) => {
if (this.showDiff) {
checkbox.checked = true;
}
checkbox.addEventListener("input", (evt: any) => {
this.showDiff = checkbox.checked;
this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null);
this.updateDiffNavVisibility();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
});
label.appendText("Highlight diff");
});
const diffOnlyLabel = diffOptionsRow.createEl("label", {});
diffOnlyLabel.createEl("input", { type: "checkbox" }, (checkbox) => {
if (this.diffOnly) {
checkbox.checked = true;
}
checkbox.addEventListener("input", (evt: any) => {
this.diffOnly = checkbox.checked;
this.app.saveLocalStorage("ols-history-diffonly", this.diffOnly == true ? "1" : null);
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
});
diffOnlyLabel.appendText("Diff only");
diffOnlyLabel.addClass("diff-only-label");
diffOnlyLabel.style.display = this.showDiff ? "inline-block" : "none";
this.diffOnlyLabel = diffOnlyLabel;
// Diff navigation buttons
this.diffNavContainer = diffOptionsRow.createDiv("");
this.diffNavContainer.addClass("diff-nav");

View File

@@ -248,7 +248,7 @@
</td>
<td class="path">
<div class="filenames">
<span class="path">/{entry.dirname.split("/").join(`/`)}</span>
<span class="path">/{entry.dirname.split("/").join(`\u200b/`)}</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-missing-attribute -->

View File

@@ -27,6 +27,9 @@ export class ConflictResolveModal extends Modal {
localName: string = "Base";
remoteName: string = "Conflicted";
offEvent?: ReturnType<typeof eventHub.onEvent>;
currentDiffIndex = -1;
diffView!: HTMLDivElement;
diffNavIndicator!: HTMLSpanElement;
constructor(app: App, filename: string, diff: diff_result, pluginPickMode?: boolean, remoteName?: string) {
super(app);
@@ -47,7 +50,7 @@ export class ConflictResolveModal extends Modal {
const lines = text.split("\n");
lines.forEach((line, index) => {
const span = container.createSpan({ cls });
span.textContent = line;
span.setText(line);
if (index < lines.length - 1) {
container.createSpan({ cls: "ls-mark-cr" });
container.createEl("br");
@@ -62,6 +65,33 @@ export class ConflictResolveModal extends Modal {
container.createEl("br");
}
navigateDiff(direction: "prev" | "next") {
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
if (diffElements.length === 0) return;
const prevFocused = this.diffView.querySelector(".diff-focused");
if (prevFocused) {
prevFocused.classList.remove("diff-focused");
}
if (direction === "next") {
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
} else {
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
}
const target = diffElements[this.currentDiffIndex];
target.classList.add("diff-focused");
target.scrollIntoView({ behavior: "smooth", block: "center" });
this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`);
}
resetDiffNavigation() {
this.currentDiffIndex = -1;
const diffElements = this.diffView.querySelectorAll(".added, .deleted");
this.diffNavIndicator.setText(diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014");
}
override onOpen() {
const { contentEl } = this;
// Send cancel signal for the previous merge dialogue
@@ -78,10 +108,26 @@ export class ConflictResolveModal extends Modal {
// sendValue("close-resolve-conflict:" + this.filename, false);
this.titleEl.setText(this.title);
contentEl.empty();
contentEl.createEl("span", { text: this.filename });
const div = contentEl.createDiv("");
div.addClass("op-scrollable");
div.addClass("ls-dialog");
const diffOptionsRow = contentEl.createDiv("");
diffOptionsRow.addClass("diff-options-row");
diffOptionsRow.createEl("span", { text: this.filename });
const diffNavContainer = diffOptionsRow.createDiv("");
diffNavContainer.addClass("diff-nav");
diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => {
e.addClass("diff-nav-btn");
e.addEventListener("click", () => this.navigateDiff("prev"));
});
diffNavContainer.createEl("button", { text: "\u25BC Next" }, (e) => {
e.addClass("diff-nav-btn");
e.addEventListener("click", () => this.navigateDiff("next"));
});
this.diffNavIndicator = diffNavContainer.createEl("span", { text: "\u2014" });
this.diffNavIndicator.addClass("diff-nav-indicator");
this.diffView = contentEl.createDiv("");
this.diffView.addClass("op-scrollable");
this.diffView.addClass("ls-dialog");
let diffLength = 0;
for (const v of this.result.diff) {
const x1 = v[0];
@@ -91,12 +137,11 @@ export class ConflictResolveModal extends Modal {
continue;
}
if (x1 == DIFF_DELETE) {
this.appendDiffFragment(div, x2, "deleted");
div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
this.appendDiffFragment(this.diffView, x2, "deleted");
} else if (x1 == DIFF_EQUAL) {
this.appendDiffFragment(div, x2, "normal");
this.appendDiffFragment(this.diffView, x2, "normal");
} else if (x1 == DIFF_INSERT) {
this.appendDiffFragment(div, x2, "added");
this.appendDiffFragment(this.diffView, x2, "added");
}
}
@@ -108,24 +153,30 @@ export class ConflictResolveModal extends Modal {
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
this.appendVersionInfo(div2, "deleted", this.localName, date1);
this.appendVersionInfo(div2, "added", this.remoteName, date2);
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
).style.marginRight = "4px";
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) =>
e.addEventListener("click", () => this.sendResponse(this.result.left.rev))
).style.marginRight = "4px";
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
});
contentEl.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
});
if (!this.pluginPickMode) {
contentEl.createEl("button", { text: "Concat both" }, (e) =>
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT))
).style.marginRight = "4px";
contentEl.createEl("button", { text: "Concat both" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
});
}
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
e.addEventListener("click", () => this.sendResponse(CANCELLED))
).style.marginRight = "4px";
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
e.addClass("conflict-action-button");
e.addEventListener("click", () => this.sendResponse(CANCELLED));
});
if (diffLength > 100 * 1024) {
div.empty();
div.innerText = "(Too large diff to display)";
this.diffView.empty();
this.diffView.setText("(Too large diff to display)");
}
this.resetDiffNavigation();
this.navigateDiff("next");
}
sendResponse(result: MergeDialogResult) {

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { logMessages } from "../../../lib/src/mock_and_interop/stores";
import { reactive, type ReactiveInstance } from "octagonal-wheels/dataobject/reactive";
import { Logger } from "../../../lib/src/common/logger";
import { $msg as msg, currentLang as lang } from "../../../lib/src/common/i18n.ts";
import { onDestroy, onMount } from "svelte";
import { logMessages } from "../../../lib/src/mock_and_interop/stores";
import { reactive, type ReactiveInstance } from "octagonal-wheels/dataobject/reactive";
import { Logger } from "../../../lib/src/common/logger";
import { $msg as msg, currentLang as lang } from "../../../lib/src/common/i18n.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
let unsubscribe: () => void;
let unsubscribe: () => void;
let messages = $state([] as string[]);
let wrapRight = $state(false);
let autoScroll = $state(true);
@@ -16,90 +17,90 @@
};
let { close }: Props = $props();
// export let close: () => void;
function updateLog(logs: ReactiveInstance<string[]>) {
const e = logs.value;
if (!suspended) {
messages = [...e];
setTimeout(() => {
if (scroll) scroll.scrollTop = scroll.scrollHeight;
}, 10);
}
}
onMount(async () => {
const _logMessages = reactive(() => logMessages.value);
_logMessages.onChanged(updateLog);
Logger(msg("logPane.logWindowOpened", {}, lang));
unsubscribe = () => _logMessages.offChanged(updateLog);
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
});
let scroll: HTMLDivElement;
function updateLog(logs: ReactiveInstance<string[]>) {
const e = logs.value;
if (!suspended) {
messages = [...e];
compatGlobal.setTimeout(() => {
if (scroll) scroll.scrollTop = scroll.scrollHeight;
}, 10);
}
}
onMount(async () => {
const _logMessages = reactive(() => logMessages.value);
_logMessages.onChanged(updateLog);
Logger(msg("logPane.logWindowOpened", {}, lang));
unsubscribe = () => _logMessages.offChanged(updateLog);
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
});
let scroll: HTMLDivElement;
function closeDialogue() {
close();
}
</script>
<div class="logpane">
<!-- <h1>{msg("logPane.title", {}, lang)}</h1> -->
<div class="control">
<div class="row">
<label>
<input type="checkbox" bind:checked={wrapRight} />
<span>{msg("logPane.wrap", {}, lang)}</span>
</label>
<label>
<input type="checkbox" bind:checked={autoScroll} />
<span>{msg("logPane.autoScroll", {}, lang)}</span>
</label>
<label>
<input type="checkbox" bind:checked={suspended} />
<span>{msg("logPane.pause", {}, lang)}</span>
</label>
<!-- <h1>{msg("logPane.title", {}, lang)}</h1> -->
<div class="control">
<div class="row">
<label>
<input type="checkbox" bind:checked={wrapRight} />
<span>{msg("logPane.wrap", {}, lang)}</span>
</label>
<label>
<input type="checkbox" bind:checked={autoScroll} />
<span>{msg("logPane.autoScroll", {}, lang)}</span>
</label>
<label>
<input type="checkbox" bind:checked={suspended} />
<span>{msg("logPane.pause", {}, lang)}</span>
</label>
<span class="spacer"></span>
<button onclick={() => closeDialogue()}>Close</button>
</div>
</div>
<div class="log" bind:this={scroll}>
{#each messages as line}
<pre class:wrap-right={wrapRight}>{line}</pre>
{/each}
</div>
</div>
</div>
<div class="log" bind:this={scroll}>
{#each messages as line}
<pre class:wrap-right={wrapRight}>{line}</pre>
{/each}
</div>
</div>
<style>
* {
box-sizing: border-box;
}
.logpane {
display: flex;
height: 100%;
flex-direction: column;
}
.log {
overflow-y: scroll;
user-select: text;
* {
box-sizing: border-box;
}
.logpane {
display: flex;
height: 100%;
flex-direction: column;
}
.log {
overflow-y: scroll;
user-select: text;
-webkit-user-select: text;
padding-bottom: 2em;
}
.log > pre {
margin: 0;
}
.log > pre.wrap-right {
word-break: break-all;
max-width: 100%;
width: 100%;
white-space: normal;
}
.row {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.row > label {
display: flex;
align-items: center;
min-width: 5em;
margin-right: 1em;
}
padding-bottom: 2em;
}
.log > pre {
margin: 0;
}
.log > pre.wrap-right {
word-break: break-all;
max-width: 100%;
width: 100%;
white-space: normal;
}
.row {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.row > label {
display: flex;
align-items: center;
min-width: 5em;
margin-right: 1em;
}
</style>

View File

@@ -2,7 +2,7 @@ import { WorkspaceLeaf } from "@/deps.ts";
import LogPaneComponent from "./LogPane.svelte";
import type ObsidianLiveSyncPlugin from "../../../main.ts";
import { SvelteItemView } from "../../../common/SvelteItemView.ts";
import { $msg } from "src/lib/src/common/i18n.ts";
import { $msg } from "@lib/common/i18n.ts";
import { mount } from "svelte";
export const VIEW_TYPE_LOG = "log-log";
//Log view

View File

@@ -88,7 +88,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
return false;
}
} else {
this._log(`Merge: Something went wrong: ${filename}, (${toDelete})`, LOG_LEVEL_NOTICE);
this._log(`Merge: Something went wrong: ${filename}, (${toDelete as string})`, LOG_LEVEL_NOTICE);
return false;
}
// In here, some merge has been processed.
@@ -163,7 +163,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
this._log(`There are no conflicting files`, LOG_LEVEL_VERBOSE);
}
} catch (e) {
this._log(`Error while scanning conflicted files: ${e}`, LOG_LEVEL_NOTICE);
this._log(`Error while scanning conflicted files...`, LOG_LEVEL_NOTICE);
this._log(e, LOG_LEVEL_VERBOSE);
return false;
}

View File

@@ -29,7 +29,7 @@ import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type Workspace
import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts";
import { serialized } from "octagonal-wheels/concurrency/lock";
import { $msg } from "src/lib/src/common/i18n.ts";
import { $msg } from "@lib/common/i18n.ts";
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector.ts";
import type { LiveSyncCore } from "../../main.ts";
import { LiveSyncError } from "@lib/common/LSError.ts";
@@ -516,7 +516,12 @@ ${stringifyYaml(info)}
let errorInfo = "";
if (message instanceof Error) {
if (message instanceof LiveSyncError) {
errorInfo = `${message.cause?.name}:${message.cause?.message}\n[StackTrace]: ${message.stack}\n[CausedBy]: ${message.cause?.stack}`;
if (message.cause && message.cause instanceof Error) {
const causedError = message.cause;
errorInfo = `${causedError?.name}:${causedError?.message}\n[StackTrace]: ${message.stack}\n[CausedBy]: ${causedError?.stack}`;
} else {
errorInfo = `${message.name}:${message.message}\n[StackTrace]: ${message.stack}`;
}
} else {
const thisStack = new Error().stack;
errorInfo = `${message.name}:${message.message}\n[StackTrace]: ${message.stack}\n[LogCallStack]: ${thisStack}`;

View File

@@ -8,12 +8,7 @@ import {
type ValueComponent,
} from "@/deps.ts";
import { unique } from "octagonal-wheels/collection";
import {
LEVEL_ADVANCED,
LEVEL_POWER_USER,
statusDisplay,
type ConfigurationItem,
} from "../../../lib/src/common/types.ts";
import { LEVEL_ADVANCED, LEVEL_POWER_USER, statusDisplay, type ConfigurationItem } from "@lib/common/types.ts";
import { createStub, type ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import {
type AllSettingItemKey,
@@ -23,7 +18,7 @@ import {
type AllNumericItemKey,
type AllBooleanItemKey,
} from "./settingConstants.ts";
import { $msg } from "src/lib/src/common/i18n.ts";
import { $msg } from "@lib/common/i18n.ts";
import { findAttrFromParent, wrapMemo, type AutoWireOption, type OnUpdateResult } from "./SettingPane.ts";
export class LiveSyncSetting extends Setting {

View File

@@ -62,6 +62,7 @@ import { paneAdvanced } from "./PaneAdvanced.ts";
import { panePowerUsers } from "./PanePowerUsers.ts";
import { panePatches } from "./PanePatches.ts";
import { paneMaintenance } from "./PaneMaintenance.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
// For creating a document
const toc = new Set<string>();
@@ -141,7 +142,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
async saveLocalSetting(key: keyof typeof OnDialogSettingsDefault) {
if (key == "configPassphrase") {
localStorage.setItem("ls-setting-passphrase", this.editingSettings?.[key] ?? "");
compatGlobal.localStorage.setItem("ls-setting-passphrase", this.editingSettings?.[key] ?? "");
return await Promise.resolve();
}
if (key == "deviceAndVaultName") {
@@ -180,7 +181,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
// if (runOnSaved) {
const handlers = this.onSavedHandlers
.filter((e) => appliedKeys.indexOf(e.key) !== -1)
.map((e) => e.handler(this.editingSettings[e.key as AllSettingItemKey]));
.map((e) => Promise.resolve(e.handler(this.editingSettings[e.key as AllSettingItemKey])));
await Promise.all(handlers);
// }
keys.forEach((e) => this.refreshSetting(e));
@@ -214,7 +215,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
reloadAllLocalSettings() {
const ret = { ...OnDialogSettingsDefault };
ret.configPassphrase = localStorage.getItem("ls-setting-passphrase") || "";
ret.configPassphrase = compatGlobal.localStorage.getItem("ls-setting-passphrase") || "";
ret.preset = "";
ret.deviceAndVaultName = this.services.setting.getDeviceAndVaultName();
return ret;
@@ -349,7 +350,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
createEl<T extends keyof HTMLElementTagNameMap>(
el: HTMLElement,
tag: T,
o?: string | DomElementInfo | undefined,
o?: string | DomElementInfo,
callback?: (el: HTMLElementTagNameMap[T]) => void,
func?: OnUpdateFunc
) {
@@ -361,7 +362,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
addEl<T extends keyof HTMLElementTagNameMap>(
el: HTMLElement,
tag: T,
o?: string | DomElementInfo | undefined,
o?: string | DomElementInfo,
callback?: (el: HTMLElementTagNameMap[T]) => void,
func?: OnUpdateFunc
) {
@@ -647,7 +648,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.editingSettings.passphrase = "";
}
await this.saveAllDirtySettings();
await this.applyAllSettings();
await Promise.resolve(this.applyAllSettings());
if (result == OPTION_FETCH) {
await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG3_HR, "");
this.services.appLifecycle.scheduleRestart();
@@ -738,6 +739,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
);
}
setLevelClass(el, level);
// TODO: Refactor to use Obsidian's recommended way to create heading.
// eslint-disable-next-line obsidianmd/settings-tab/no-manual-html-headings
el.createEl("h3", { text: title, cls: "sls-setting-pane-title" });
if (this.menuEl) {
this.menuEl.createEl(

View File

@@ -1,29 +1,16 @@
import { stringifyYaml } from "../../../deps.ts";
import {
type ObsidianLiveSyncSettings,
type FilePathWithPrefix,
type DocumentID,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
type LoadedEntry,
REMOTE_COUCHDB,
REMOTE_MINIO,
type MetaEntry,
type FilePath,
DEFAULT_SETTINGS,
} from "../../../lib/src/common/types.ts";
import {
createBlob,
getFileRegExp,
isDocContentSame,
parseHeaderValues,
readAsBlob,
} from "../../../lib/src/common/utils.ts";
import { Logger } from "../../../lib/src/common/logger.ts";
import { isCloudantURI } from "../../../lib/src/pouchdb/utils_couchdb.ts";
import { requestToCouchDBWithCredentials } from "../../../common/utils.ts";
import { addPrefix, shouldBeIgnored, stripAllPrefixes } from "../../../lib/src/string_and_binary/path.ts";
import { $msg } from "../../../lib/src/common/i18n.ts";
} from "@lib/common/types.ts";
import { createBlob, getFileRegExp, isDocContentSame, readAsBlob } from "@lib/common/utils.ts";
import { Logger } from "@lib/common/logger.ts";
import { addPrefix, shouldBeIgnored, stripAllPrefixes } from "@lib/string_and_binary/path.ts";
import { $msg } from "@lib/common/i18n.ts";
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
import {
@@ -32,14 +19,13 @@ import {
EVENT_REQUEST_RUN_DOCTOR,
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
eventHub,
} from "../../../common/events.ts";
import { ICHeader, ICXHeader, PSCHeader } from "../../../common/types.ts";
import { HiddenFileSync } from "../../../features/HiddenFileSync/CmdHiddenFileSync.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts";
import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts";
} from "@/common/events.ts";
import { ICHeader, ICXHeader, PSCHeader } from "@/common/types.ts";
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
import type { PageFunctions } from "./SettingPane.ts";
import { generateReport } from "@/common/reportTool.ts";
import { isNotFoundError } from "@lib/common/utils.doc.ts";
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
// hatchWarn.addClass("op-warn-info");
@@ -160,14 +146,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
}
if (!(await addOn.storeInternalFileToDatabase(file, true))) {
Logger(
`Failed to store the file to the database (Hidden file): ${file}`,
`Failed to store the file to the database (Hidden file): ${file.path}`,
LOG_LEVEL_NOTICE
);
return;
}
}
} else {
if (!(await this.core.fileHandler.storeFileToDB(file as FilePath, true))) {
if (!(await this.core.fileHandler.storeFileToDB(file, true))) {
Logger(
`Failed to store the file to the database: ${file}`,
LOG_LEVEL_NOTICE
@@ -406,8 +392,8 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE);
Logger(ret, LOG_LEVEL_VERBOSE);
}
} catch (ex: any) {
if (ex?.status == 404) {
} catch (ex: unknown) {
if (isNotFoundError(ex)) {
// We can perform this safely
if ((await this.core.localDatabase.putRaw(newDoc)).ok) {
Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE);

View File

@@ -150,7 +150,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
xxhash64: "xxhash64 (Fastest)",
"mixed-purejs": "PureJS fallback (Fast, W/O WebAssembly)",
sha1: "Older fallback (Slow, W/O WebAssembly)",
} as Record<HashAlgorithm, string>,
} satisfies Record<HashAlgorithm, string>,
});
this.addOnSaved("hashAlg", async () => {
await this.core.localDatabase._prepareHashFunctions();
@@ -188,7 +188,7 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
}
this.requestUpdate();
};
text.inputEl.before((dateEl = document.createElement("span")));
text.inputEl.before((dateEl = activeDocument.createElement("span")));
text.inputEl.type = "datetime-local";
if (this.editingSettings.maxMTimeForReflectEvents > 0) {
const date = new Date(this.editingSettings.maxMTimeForReflectEvents);

View File

@@ -5,6 +5,7 @@ import {
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
type ObsidianLiveSyncSettings,
LOG_LEVEL_VERBOSE,
} from "../../../lib/src/common/types.ts";
import { Menu } from "@/deps.ts";
import { $msg } from "../../../lib/src/common/i18n.ts";
@@ -288,7 +289,8 @@ export function paneRemoteConfig(
try {
parsed = ConnectionStringParser.parse(trimmedURI);
} catch (ex) {
this.services.API.addLog(`Failed to import remote configuration: ${ex}`, LOG_LEVEL_NOTICE);
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
return;
}
@@ -343,9 +345,10 @@ export function paneRemoteConfig(
parsed = ConnectionStringParser.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}' for editing: ${ex}`,
`Failed to parse remote configuration '${config.id}' for editing!`,
LOG_LEVEL_NOTICE
);
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
return;
}
const workSettings = createBaseRemoteSettings();
@@ -452,9 +455,10 @@ export function paneRemoteConfig(
parsed = ConnectionStringParser.parse(config.uri);
} catch (ex) {
this.services.API.addLog(
`Failed to parse remote configuration '${config.id}': ${ex}`,
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
LOG_LEVEL_NOTICE
);
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
return;
}
const workSettings = createBaseRemoteSettings();

View File

@@ -75,7 +75,7 @@ export function getSummaryFromPartialSettings(setting: Partial<ObsidianLiveSyncS
if (config.isAdvanced && !showAdvanced) continue;
const value =
key != "E2EEAlgorithm"
? `${setting[key]}`
? `${setting[key] as string}`
: E2EEAlgorithmNames[`${setting[key]}` as keyof typeof E2EEAlgorithmNames];
const displayValue = config.isHidden ? "•".repeat(value.length) : escapeStringToHTML(value);
outputSummary[config.name] = displayValue;

View File

@@ -1,13 +1,17 @@
import {
type BucketSyncSetting,
type CouchDBConnection,
type EncryptionSettings,
type ObsidianLiveSyncSettings,
type P2PSyncSetting,
DEFAULT_SETTINGS,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "../../lib/src/common/types.ts";
import { generatePatchObj, isObjectDifferent } from "../../lib/src/common/utils.ts";
} from "@lib/common/types.ts";
import { isObjectDifferent } from "@lib/common/utils.ts";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
import SelectMethodExisting from "./SetupWizard/dialogs/SelectMethodExisting.svelte";
@@ -21,8 +25,21 @@ import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte"
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts";
import { decodeSettingsFromQRCodeData } from "@lib/API/processSetting.ts";
import { AbstractModule } from "../AbstractModule.ts";
import { ConnectionStringParser } from "@lib/common/ConnectionString.ts";
import type {
OutroAskUserModeResultType,
OutroExistingUserResultType,
OutroNewUserResultType,
ScanQRCodeResultType,
SetupRemoteBucketResultType,
SetupRemoteCouchDBResultType,
SetupRemoteE2EEResultType,
SetupRemoteP2PResultType,
SetupRemoteResultType,
UseSetupURIResultType,
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
/**
* User modes for onboarding and setup
@@ -117,7 +134,10 @@ export class SetupManager extends AbstractModule {
* @returns Promise that resolves to true if onboarding completed successfully, false otherwise
*/
async onUseSetupURI(userMode: UserMode, setupURI: string = ""): Promise<boolean> {
const newSetting = await this.dialogManager.openWithExplicitCancel(UseSetupURI, setupURI);
const newSetting = await this.dialogManager.openWithExplicitCancel<UseSetupURIResultType, string>(
UseSetupURI,
setupURI
);
if (newSetting === "cancelled") {
this._log("Setup URI dialog cancelled.", LOG_LEVEL_NOTICE);
return false;
@@ -140,7 +160,10 @@ export class SetupManager extends AbstractModule {
): Promise<boolean> {
const originalSetting = JSON.parse(JSON.stringify(currentSetting)) as ObsidianLiveSyncSettings;
const baseSetting = JSON.parse(JSON.stringify(originalSetting)) as ObsidianLiveSyncSettings;
const couchConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, originalSetting);
const couchConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteCouchDBResultType,
CouchDBConnection
>(SetupRemoteCouchDB, originalSetting);
if (couchConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
@@ -164,7 +187,10 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const bucketConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteBucket, currentSetting);
const bucketConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteBucketResultType,
BucketSyncSetting
>(SetupRemoteBucket, currentSetting);
if (bucketConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
@@ -188,14 +214,33 @@ export class SetupManager extends AbstractModule {
currentSetting: ObsidianLiveSyncSettings,
activate = true
): Promise<boolean> {
const p2pConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteP2P, currentSetting);
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
SetupRemoteP2P,
currentSetting
);
if (p2pConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
}
const newSetting = { ...currentSetting, ...p2pConf } as ObsidianLiveSyncSettings;
// Apply remoteConfigurations
if (newSetting.P2P_ActiveRemoteConfigurationId) {
const id = newSetting.P2P_ActiveRemoteConfigurationId;
const merged = {
...newSetting,
...p2pConf,
} as ObsidianLiveSyncSettings;
const uri = ConnectionStringParser.serialize({ type: "p2p", settings: merged });
newSetting.remoteConfigurations[id] = {
...newSetting.remoteConfigurations[id],
uri,
isEncrypted: false,
};
newSetting.P2P_ActiveRemoteConfigurationId = id;
}
if (activate) {
newSetting.remoteType = REMOTE_P2P;
newSetting.activeConfigurationId = newSetting.P2P_ActiveRemoteConfigurationId;
}
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
@@ -207,10 +252,13 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onlyE2EEConfiguration(userMode: UserMode, currentSetting: ObsidianLiveSyncSettings): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, currentSetting);
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
SetupRemoteE2EE,
currentSetting
);
if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return await false;
return false;
}
const newSetting = {
...currentSetting,
@@ -226,7 +274,10 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onConfigureManually(originalSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const e2eeConf = await this.dialogManager.openWithExplicitCancel(SetupRemoteE2EE, originalSetting);
const e2eeConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteE2EEResultType, EncryptionSettings>(
SetupRemoteE2EE,
originalSetting
);
if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
@@ -245,7 +296,7 @@ export class SetupManager extends AbstractModule {
* @returns
*/
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
const method = await this.dialogManager.openWithExplicitCancel(SetupRemote);
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
if (method === "couchdb") {
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
} else if (method === "bucket") {
@@ -285,9 +336,9 @@ export class SetupManager extends AbstractModule {
this._log("No changes in settings detected. Skipping applying settings from wizard.", LOG_LEVEL_NOTICE);
return true;
}
const patch = generatePatchObj(this.settings, newConf);
console.log(`Changes:`);
console.dir(patch);
// const patch = generatePatchObj(this.settings, newConf);
// console.log(`Changes:`);
// console.dir(patch);
if (!activate) {
extra();
await this.applySetting(newConf, UserMode.ExistingUser);
@@ -304,7 +355,8 @@ export class SetupManager extends AbstractModule {
this._log("Settings from wizard applied.", LOG_LEVEL_NOTICE);
return true;
} else {
const userModeResult = await this.dialogManager.openWithExplicitCancel(OutroAskUserMode);
const userModeResult =
await this.dialogManager.openWithExplicitCancel<OutroAskUserModeResultType>(OutroAskUserMode);
if (userModeResult === "new-user") {
userMode = UserMode.NewUser;
} else if (userModeResult === "existing-user") {
@@ -321,7 +373,9 @@ export class SetupManager extends AbstractModule {
}
}
const component = userMode === UserMode.NewUser ? OutroNewUser : OutroExistingUser;
const confirm = await this.dialogManager.openWithExplicitCancel(component);
const confirm = await this.dialogManager.openWithExplicitCancel<
OutroNewUserResultType | OutroExistingUserResultType
>(component);
if (confirm === "cancelled") {
this._log("User cancelled applying settings from wizard..", LOG_LEVEL_NOTICE);
return false;
@@ -347,10 +401,10 @@ export class SetupManager extends AbstractModule {
*/
async onPromptQRCodeInstruction(): Promise<boolean> {
const qrResult = await this.dialogManager.open(ScanQRCode);
const qrResult = await this.dialogManager.open<ScanQRCodeResultType>(ScanQRCode);
this._log("QR Code dialog closed.", LOG_LEVEL_VERBOSE);
// Result is not used, but log it for debugging.
this._log(`QR Code result: ${qrResult}`, LOG_LEVEL_VERBOSE);
this._log(qrResult, LOG_LEVEL_VERBOSE);
// QR Code instruction dialog never yields settings directly.
return false;
}

View File

@@ -1,47 +1,30 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_IDENTICAL = "identical";
const TYPE_INDEPENDENT = "independent";
const TYPE_UNBALANCED = "unbalanced";
const TYPE_CANCEL = "cancelled";
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
import Guidance from "@lib/UI/components/Guidance.svelte";
import Decision from "@lib/UI/components/Decision.svelte";
import Question from "@lib/UI/components/Question.svelte";
import Option from "@lib/UI/components/Option.svelte";
import Options from "@lib/UI/components/Options.svelte";
import Instruction from "@lib/UI/components/Instruction.svelte";
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
import InfoNote from "@lib/UI/components/InfoNote.svelte";
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
import Check from "@lib/UI/components/Check.svelte";
import {
TYPE_BACKUP_DONE,
TYPE_BACKUP_SKIPPED,
TYPE_CANCEL,
TYPE_IDENTICAL,
TYPE_INDEPENDENT,
TYPE_UNABLE_TO_BACKUP,
TYPE_UNBALANCED,
type FetchEverythingResult,
type ResultTypeBackup,
type ResultTypeVault,
} from "./setupDialogTypes";
const TYPE_BACKUP_DONE = "backup_done";
const TYPE_BACKUP_SKIPPED = "backup_skipped";
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
type ResultTypeVault =
| typeof TYPE_IDENTICAL
| typeof TYPE_INDEPENDENT
| typeof TYPE_UNBALANCED
| typeof TYPE_CANCEL;
type ResultTypeBackup =
| typeof TYPE_BACKUP_DONE
| typeof TYPE_BACKUP_SKIPPED
| typeof TYPE_UNABLE_TO_BACKUP
| typeof TYPE_CANCEL;
type ResultTypeExtra = {
preventFetchingConfig: boolean;
};
type ResultType =
| {
vault: ResultTypeVault;
backup: ResultTypeBackup;
extra: ResultTypeExtra;
}
| typeof TYPE_CANCEL;
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: FetchEverythingResult) => void;
};
const { setResult }: Props = $props();
let vaultType = $state<ResultTypeVault>(TYPE_CANCEL);

View File

@@ -1,21 +1,19 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Option from "@/lib/src/UI/components/Option.svelte";
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_NEW_USER = "new-user";
const TYPE_EXISTING_USER = "existing-user";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_NEW_USER | typeof TYPE_EXISTING_USER | typeof TYPE_CANCELLED;
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
import Guidance from "@lib/UI/components/Guidance.svelte";
import Decision from "@lib/UI/components/Decision.svelte";
import Question from "@lib/UI/components/Question.svelte";
import Option from "@lib/UI/components/Option.svelte";
import Options from "@lib/UI/components/Options.svelte";
import Instruction from "@lib/UI/components/Instruction.svelte";
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
import { TYPE_NEW_USER, TYPE_EXISTING_USER, TYPE_CANCELLED, type IntroResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: IntroResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let userType = $state<IntroResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_NEW_USER) {
return "Yes, I want to set up a new synchronisation";

View File

@@ -7,16 +7,19 @@
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
const TYPE_EXISTING = "existing-user";
const TYPE_NEW = "new-user";
const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_EXISTING | typeof TYPE_NEW | typeof TYPE_COMPATIBLE_EXISTING | typeof TYPE_CANCELLED;
import {
type OutroAskUserModeResultType,
TYPE_CANCELLED,
TYPE_EXISTING,
TYPE_NEW,
TYPE_COMPATIBLE_EXISTING,
} from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: OutroAskUserModeResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let userType = $state<OutroAskUserModeResultType>(TYPE_CANCELLED);
const canProceed = $derived.by(() => {
return userType === TYPE_EXISTING || userType === TYPE_NEW || userType === TYPE_COMPATIBLE_EXISTING;
});
@@ -41,7 +44,11 @@
</Guidance>
<Instruction>
<Question>Please select your situation.</Question>
<Option title="I am setting up a new server for the first time / I want to reset my existing server." bind:value={userType} selectedValue={TYPE_NEW}>
<Option
title="I am setting up a new server for the first time / I want to reset my existing server."
bind:value={userType}
selectedValue={TYPE_NEW}
>
<InfoNote>
Selecting this option will result in the current data on this device being used to initialise the server.
Any existing data on the server will be completely overwritten.

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Question from "@/lib/src/UI/components/Question.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_APPLY = "apply";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
import DialogHeader from "@lib/UI/components/DialogHeader.svelte";
import Guidance from "@lib/UI/components/Guidance.svelte";
import Decision from "@lib/UI/components/Decision.svelte";
import Question from "@lib/UI/components/Question.svelte";
import Instruction from "@lib/UI/components/Instruction.svelte";
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
import { TYPE_CANCELLED, TYPE_APPLY, type OutroExistingUserResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: OutroExistingUserResultType) => void;
};
const { setResult }: Props = $props();
</script>

View File

@@ -5,14 +5,13 @@
import Question from "@/lib/src/UI/components/Question.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_APPLY = "apply";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
import { TYPE_APPLY, TYPE_CANCELLED, type OutroNewUserResultType } from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: OutroNewUserResultType) => void;
};
const { setResult }: Props = $props();
// let userType = $state<ResultType>(TYPE_CANCELLED);
// let userType = $state<OutroNewUserResultType>(TYPE_CANCELLED);
</script>
<DialogHeader title="Setup Complete: Preparing to Initialise Server" />

View File

@@ -2,9 +2,9 @@
/**
* Panel to check and fix CouchDB configuration issues
*/
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
import Decision from "../../../../lib/src/UI/components/Decision.svelte";
import UserDecisions from "../../../../lib/src/UI/components/UserDecisions.svelte";
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
import Decision from "@lib/UI/components/Decision.svelte";
import UserDecisions from "@lib/UI/components/UserDecisions.svelte";
import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB";
type Props = {
trialRemoteSetting: ObsidianLiveSyncSettings;

View File

@@ -10,29 +10,17 @@
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_CANCEL = "cancelled";
import {
TYPE_CANCEL,
TYPE_BACKUP_DONE,
TYPE_BACKUP_SKIPPED,
TYPE_UNABLE_TO_BACKUP,
type RebuildEverythingResult,
type ResultTypeBackup,
} from "./setupDialogTypes";
const TYPE_BACKUP_DONE = "backup_done";
const TYPE_BACKUP_SKIPPED = "backup_skipped";
const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
type ResultTypeBackup =
| typeof TYPE_BACKUP_DONE
| typeof TYPE_BACKUP_SKIPPED
| typeof TYPE_UNABLE_TO_BACKUP
| typeof TYPE_CANCEL;
type ResultTypeExtra = {
preventFetchingConfig: boolean;
};
type ResultType =
| {
backup: ResultTypeBackup;
extra: ResultTypeExtra;
}
| typeof TYPE_CANCEL;
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: RebuildEverythingResult) => void;
};
const { setResult }: Props = $props();

View File

@@ -4,10 +4,10 @@
import Decision from "@/lib/src/UI/components/Decision.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_CLOSE = "close";
type ResultType = typeof TYPE_CLOSE;
import { TYPE_CLOSE, type ScanQRCodeResultType } from "./setupDialogTypes";
type Props = {
setResult: (_result: ResultType) => void;
setResult: (_result: ScanQRCodeResultType) => void;
};
const { setResult }: Props = $props();
</script>

View File

@@ -7,19 +7,19 @@
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_USE_SETUP_URI = "use-setup-uri";
const TYPE_SCAN_QR_CODE = "scan-qr-code";
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_SCAN_QR_CODE | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
import {
TYPE_USE_SETUP_URI,
TYPE_SCAN_QR_CODE,
TYPE_CONFIGURE_MANUALLY,
TYPE_CANCELLED,
type SelectMethodExistingResultType,
} from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: SelectMethodExistingResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let userType = $state<SelectMethodExistingResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_USE_SETUP_URI) {
return "Proceed with Setup URI";

View File

@@ -7,18 +7,18 @@
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
import InfoNote from "@/lib/src/UI/components/InfoNote.svelte";
import ExtraItems from "@/lib/src/UI/components/ExtraItems.svelte";
import Check from "@/lib/src/UI/components/Check.svelte";
const TYPE_USE_SETUP_URI = "use-setup-uri";
const TYPE_CONFIGURE_MANUALLY = "configure-manually";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_USE_SETUP_URI | typeof TYPE_CONFIGURE_MANUALLY | typeof TYPE_CANCELLED;
import {
TYPE_USE_SETUP_URI,
TYPE_CONFIGURE_MANUALLY,
TYPE_CANCELLED,
type SelectMethodNewUserResultType,
} from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: SelectMethodNewUserResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let userType = $state<SelectMethodNewUserResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_USE_SETUP_URI) {
return "Proceed with Setup URI";

View File

@@ -6,16 +6,19 @@
import Options from "@/lib/src/UI/components/Options.svelte";
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
const TYPE_COUCHDB = "couchdb";
const TYPE_BUCKET = "bucket";
const TYPE_P2P = "p2p";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
import {
TYPE_COUCHDB,
TYPE_BUCKET,
TYPE_P2P,
TYPE_CANCELLED,
type SetupRemoteResultType,
} from "./setupDialogTypes";
type Props = {
setResult: (result: ResultType) => void;
setResult: (result: SetupRemoteResultType) => void;
};
const { setResult }: Props = $props();
let userType = $state<ResultType>(TYPE_CANCELLED);
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
let proceedTitle = $derived.by(() => {
if (userType === TYPE_COUCHDB) {
return "Continue to CouchDB setup";

View File

@@ -13,19 +13,18 @@
DEFAULT_SETTINGS,
PREFERRED_JOURNAL_SYNC,
RemoteTypes,
} from "../../../../lib/src/common/types";
} from "@lib/common/types";
import { onMount } from "svelte";
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { copyTo, pickBucketSyncSettings } from "../../../../lib/src/common/utils";
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
import { copyTo, pickBucketSyncSettings } from "@lib/common/utils";
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<BucketSyncSetting>({ ...default_setting });
type ResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
type Props = GuestDialogProps<ResultType, BucketSyncSetting>;
const TYPE_CANCELLED = "cancelled";
type Props = GuestDialogProps<SetupRemoteBucketResultType, BucketSyncSetting>;
const { setResult, getInitialData }: Props = $props();

View File

@@ -14,20 +14,19 @@
RemoteTypes,
type CouchDBConnection,
type ObsidianLiveSyncSettings,
} from "../../../../lib/src/common/types";
import { isCloudantURI } from "../../../../lib/src/pouchdb/utils_couchdb";
} from "@lib/common/types";
import { isCloudantURI } from "@lib/pouchdb/utils_couchdb";
import { onMount } from "svelte";
import { getDialogContext, type GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "../../../../lib/src/common/utils";
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
import { copyTo, pickCouchDBSyncSettings } from "@lib/common/utils";
import PanelCouchDBCheck from "./PanelCouchDBCheck.svelte";
import { TYPE_CANCELLED, type SetupRemoteCouchDBResultType } from "./setupDialogTypes";
const default_setting = pickCouchDBSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<CouchDBConnection>({ ...default_setting });
type ResultType = typeof TYPE_CANCELLED | CouchDBConnection;
const TYPE_CANCELLED = "cancelled";
type Props = GuestDialogProps<ResultType, CouchDBConnection>;
type Props = GuestDialogProps<SetupRemoteCouchDBResultType, CouchDBConnection>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {
if (getInitialData) {

View File

@@ -12,13 +12,13 @@
E2EEAlgorithmNames,
E2EEAlgorithms,
type EncryptionSettings,
} from "../../../../lib/src/common/types";
} from "@lib/common/types";
import { onMount } from "svelte";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog";
import { copyTo, pickEncryptionSettings } from "../../../../lib/src/common/utils";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_CANCELLED | EncryptionSettings;
type Props = GuestDialogProps<ResultType, EncryptionSettings>;
import type { GuestDialogProps } from "@lib/UI/svelteDialog";
import { copyTo, pickEncryptionSettings } from "@lib/common/utils";
import { TYPE_CANCELLED, type SetupRemoteE2EEResultType } from "./setupDialogTypes";
type Props = GuestDialogProps<SetupRemoteE2EEResultType, EncryptionSettings>;
const { setResult, getInitialData }: Props = $props();
let default_encryption: EncryptionSettings = {
encrypt: true,

View File

@@ -26,16 +26,14 @@
import { getDialogContext, type GuestDialogProps } from "@lib/UI/svelteDialog";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
import ExtraItems from "@lib/UI/components/ExtraItems.svelte";
import { TYPE_CANCELLED, type SetupRemoteP2PResultType } from "./setupDialogTypes";
const default_setting = pickP2PSyncSettings(DEFAULT_SETTINGS);
let syncSetting = $state<P2PConnectionInfo>({ ...default_setting });
const context = getDialogContext();
let error = $state("");
const TYPE_CANCELLED = "cancelled";
type SettingInfo = P2PConnectionInfo;
type ResultType = typeof TYPE_CANCELLED | SettingInfo;
type Props = GuestDialogProps<ResultType, P2PSyncSetting>;
type Props = GuestDialogProps<SetupRemoteP2PResultType, P2PSyncSetting>;
const { setResult, getInitialData }: Props = $props();
onMount(() => {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { configURIBase } from "../../../../common/types";
import type { ObsidianLiveSyncSettings } from "../../../../lib/src/common/types";
import { configURIBase } from "@/common/types";
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
import DialogHeader from "@/lib/src/UI/components/DialogHeader.svelte";
import Guidance from "@/lib/src/UI/components/Guidance.svelte";
import Decision from "@/lib/src/UI/components/Decision.svelte";
@@ -10,11 +10,11 @@
import Password from "@/lib/src/UI/components/Password.svelte";
import { onMount } from "svelte";
import { decryptString } from "../../../../lib/src/encryption/stringEncryption.ts";
import type { GuestDialogProps } from "../../../../lib/src/UI/svelteDialog.ts";
const TYPE_CANCELLED = "cancelled";
type ResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
type Props = GuestDialogProps<ResultType, string>;
import { decryptString } from "@lib/encryption/stringEncryption.ts";
import type { GuestDialogProps } from "@lib/UI/svelteDialog.ts";
import { TYPE_CANCELLED, type UseSetupURIResultType } from "./setupDialogTypes";
type Props = GuestDialogProps<UseSetupURIResultType, string>;
const { setResult, getInitialData }: Props = $props();
let setupURI = $state("");

View File

@@ -0,0 +1,108 @@
import type {
BucketSyncSetting,
CouchDBConnection,
EncryptionSettings,
ObsidianLiveSyncSettings,
P2PConnectionInfo,
} from "@lib/common/models/setting.type";
export const TYPE_IDENTICAL = "identical";
export const TYPE_INDEPENDENT = "independent";
export const TYPE_UNBALANCED = "unbalanced";
export const TYPE_CANCEL = "cancelled";
export const TYPE_BACKUP_DONE = "backup_done";
export const TYPE_BACKUP_SKIPPED = "backup_skipped";
export const TYPE_UNABLE_TO_BACKUP = "unable_to_backup";
// Intro
export const TYPE_NEW_USER = "new-user";
export const TYPE_EXISTING_USER = "existing-user";
export const TYPE_CANCELLED = "cancelled";
// Outro ask user mode
export const TYPE_EXISTING = "existing-user";
export const TYPE_NEW = "new-user";
export const TYPE_COMPATIBLE_EXISTING = "compatible-existing-user";
// OutroExistingUser
export const TYPE_APPLY = "apply";
// Select methods
export const TYPE_USE_SETUP_URI = "use-setup-uri";
export const TYPE_SCAN_QR_CODE = "scan-qr-code";
export const TYPE_CONFIGURE_MANUALLY = "configure-manually";
// ScanQRCode
export const TYPE_CLOSE = "close";
// SetupRemote
export const TYPE_COUCHDB = "couchdb";
export const TYPE_BUCKET = "bucket";
export const TYPE_P2P = "p2p";
export type ResultTypeVault =
| typeof TYPE_IDENTICAL
| typeof TYPE_INDEPENDENT
| typeof TYPE_UNBALANCED
| typeof TYPE_CANCEL;
export type ResultTypeBackup =
| typeof TYPE_BACKUP_DONE
| typeof TYPE_BACKUP_SKIPPED
| typeof TYPE_UNABLE_TO_BACKUP
| typeof TYPE_CANCEL;
export type ResultTypeExtra = {
preventFetchingConfig: boolean;
};
export type FetchEverythingResult =
| {
vault: ResultTypeVault;
backup: ResultTypeBackup;
extra: ResultTypeExtra;
}
| typeof TYPE_CANCEL;
export type RebuildEverythingResult =
| {
backup: ResultTypeBackup;
extra: ResultTypeExtra;
}
| typeof TYPE_CANCEL;
export type IntroResultType = typeof TYPE_NEW_USER | typeof TYPE_EXISTING_USER | typeof TYPE_CANCELLED;
export type OutroAskUserModeResultType =
| typeof TYPE_EXISTING
| typeof TYPE_NEW
| typeof TYPE_COMPATIBLE_EXISTING
| typeof TYPE_CANCELLED;
export type OutroExistingUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
export type OutroNewUserResultType = typeof TYPE_APPLY | typeof TYPE_CANCELLED;
export type SelectMethodNewUserResultType =
| typeof TYPE_USE_SETUP_URI
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SelectMethodExistingResultType =
| typeof TYPE_USE_SETUP_URI
| typeof TYPE_SCAN_QR_CODE
| typeof TYPE_CONFIGURE_MANUALLY
| typeof TYPE_CANCELLED;
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
export type SetupRemoteE2EEResultType = typeof TYPE_CANCELLED | EncryptionSettings;
export type SetupRemoteBucketResultType = typeof TYPE_CANCELLED | BucketSyncSetting;
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
export type SetupRemoteP2PResultType = typeof TYPE_CANCELLED | P2PConnectionInfo;
export type ScanQRCodeResultType = typeof TYPE_CLOSE;

View File

@@ -13,6 +13,8 @@ import { AbstractModule } from "../AbstractModule.ts";
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts";
import type { LiveSyncCore } from "../../main.ts";
import { initialiseWorkerModule } from "@lib/worker/bgWorker.ts";
import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars.ts";
import { compatGlobal } from "@lib/common/coreEnvFunctions.ts";
export class ModuleLiveSyncMain extends AbstractModule {
async _onLiveSyncReady() {
@@ -89,11 +91,6 @@ export class ModuleLiveSyncMain extends AbstractModule {
return false;
}
// this.addUIs();
//@ts-ignore
const manifestVersion: string = MANIFEST_VERSION || "0.0.0";
//@ts-ignore
const packageVersion: string = PACKAGE_VERSION || "0.0.0";
this._log($msg("moduleLiveSyncMain.logPluginVersion", { manifestVersion, packageVersion }));
await this.services.setting.loadSettings();
if (!(await this.services.appLifecycle.onSettingLoaded())) {
@@ -101,7 +98,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
return false;
}
const lsKey = "obsidian-live-sync-ver" + this.services.vault.getVaultName();
const last_version = localStorage.getItem(lsKey);
const last_version = compatGlobal.localStorage.getItem(lsKey);
const lastVersion = ~~(versionNumberString2Number(manifestVersion) / 1000);
if (lastVersion > this.settings.lastReadUpdates && this.settings.isConfigured) {
@@ -123,7 +120,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
this.settings.versionUpFlash = $msg("moduleLiveSyncMain.logVersionUpdate");
await this.saveSettings();
}
localStorage.setItem(lsKey, `${VER}`);
compatGlobal.localStorage.setItem(lsKey, `${VER}`);
await this.services.database.openDatabase({
databaseEvents: this.services.databaseEvents,
replicator: this.services.replicator,
@@ -133,7 +130,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
// this.$$replicate = this.$$replicate.bind(this);
// this.core.$$onLiveSyncReady = this.core.$$onLiveSyncReady.bind(this);
await this.core.services.appLifecycle.onLoaded();
await Promise.all(this.core.addOns.map((e) => e.onload()));
await Promise.all(this.core.addOns.map((e) => Promise.resolve(e.onload())));
return true;
}

View File

@@ -5,9 +5,17 @@ import { ObsHttpHandler } from "../essentialObsidian/APILib/ObsHttpHandler";
import { ObsidianConfirm } from "./ObsidianConfirm";
import type { Confirm } from "@lib/interfaces/Confirm";
import { requestUrl, type RequestUrlParam } from "@/deps";
import { compatGlobal } from "@/lib/src/common/coreEnvFunctions";
// All Services will be migrated to be based on Plain Services, not Injectable Services.
// This is a migration step.
declare module "obsidian" {
interface App {
appId?: string;
isMobile?: boolean;
}
}
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
_customHandler: ObsHttpHandler | undefined;
_confirmInstance: Confirm;
@@ -83,8 +91,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
}
}
override isMobile(): boolean {
//@ts-ignore : internal API
return this.app.isMobile;
return "isMobile" in this.app ? !!this.app.isMobile : false;
}
override getAppID(): string {
return `${"appId" in this.app ? this.app.appId : ""}`;
@@ -95,7 +102,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
}
override getAppVersion(): string {
const navigatorString = globalThis.navigator?.userAgent ?? "";
const navigatorString = compatGlobal.navigator?.userAgent ?? "";
const match = navigatorString.match(/obsidian\/([0-9]+\.[0-9]+\.[0-9]+)/);
if (match && match.length >= 2) {
return match[1];
@@ -196,7 +203,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
}
override setInterval(handler: () => void, timeout: number): number {
const timerId = globalThis.setInterval(handler, timeout) as unknown as number;
const timerId = compatGlobal.setInterval(handler, timeout);
this.context.plugin.registerInterval(timerId);
return timerId;
}

View File

@@ -1,3 +1,4 @@
import { compatGlobal } from "@/lib/src/common/coreEnvFunctions";
import { type ObsidianLiveSyncSettings } from "@lib/common/types";
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
import { eventHub } from "@lib/hub/hub";
@@ -17,13 +18,16 @@ export class ObsidianSettingService<T extends ObsidianServiceContext> extends Se
});
}
protected setItem(key: string, value: string) {
return localStorage.setItem(key, value);
// TODO: Implement nativeLocalStorage.
return compatGlobal.localStorage.setItem(key, value);
}
protected getItem(key: string): string {
return localStorage.getItem(key) ?? "";
// TODO: Implement nativeLocalStorage.
return compatGlobal.localStorage.getItem(key) ?? "";
}
protected deleteItem(key: string): void {
localStorage.removeItem(key);
// TODO: Implement nativeLocalStorage.
compatGlobal.localStorage.removeItem(key);
}
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {

View File

@@ -8,6 +8,10 @@ import { extractObject } from "octagonal-wheels/object";
import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const";
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
import type {
FetchEverythingResult,
RebuildEverythingResult,
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes";
/**
* Flag file handler interface, similar to target filter pattern.
@@ -65,7 +69,8 @@ export function createFetchAllFlagHandler(
// Handle the fetch all scheduled operation
const onScheduled = async () => {
const method = await host.services.UI.dialogManager.openWithExplicitCancel(FetchEverything);
const method =
await host.services.UI.dialogManager.openWithExplicitCancel<FetchEverythingResult>(FetchEverything);
if (method === "cancelled") {
log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
await cleanupFlag();
@@ -73,7 +78,7 @@ export function createFetchAllFlagHandler(
return false;
}
const { vault, extra } = method;
const settings = await host.services.setting.currentSettings();
const settings = await Promise.resolve(host.services.setting.currentSettings());
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
const makeLocalChunkBeforeSyncAvailable = settings.remoteType !== REMOTE_MINIO;
const mapVaultStateToAction = {
@@ -296,7 +301,8 @@ export function createRebuildFlagHandler(
// Handle the rebuild everything scheduled operation
const onScheduled = async () => {
const method = await host.services.UI.dialogManager.openWithExplicitCancel(RebuildEverything);
const method =
await host.services.UI.dialogManager.openWithExplicitCancel<RebuildEverythingResult>(RebuildEverything);
if (method === "cancelled") {
log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
await cleanupFlag();

View File

@@ -17,6 +17,10 @@
min-width: 5em;
}
.conflict-action-button {
margin-right: 4px;
}
.op-scrollable {
overflow-y: scroll;
/* min-height: 280px; */
@@ -167,7 +171,7 @@ body {
.sls-setting-menu-buttons {
border: 1px solid var(--sls-col-warn);
padding: 2px;
/* padding: 2px; */
margin: 1px;
border-radius: 4px;
background-image: linear-gradient(-45deg,
@@ -176,7 +180,7 @@ body {
background-size: 30px 30px;
display: flex;
flex-direction: row;
justify-content: flex-end;
/* justify-content: flex-end; */
padding: 0.5em 0.25em;
justify-content: center;
align-items: center;
@@ -311,9 +315,9 @@ body {
.sls-setting-obsolete {
background-image: linear-gradient(-45deg,
/* background-image: linear-gradient(-45deg,
var(--sls-col-warn-stripe1) 25%, var(--sls-col-warn-stripe2) 25%, var(--sls-col-warn-stripe2) 50%,
var(--sls-col-warn-stripe1) 50%, var(--sls-col-warn-stripe1) 75%, var(--sls-col-warn-stripe2) 75%, var(--sls-col-warn-stripe2));
var(--sls-col-warn-stripe1) 50%, var(--sls-col-warn-stripe1) 75%, var(--sls-col-warn-stripe2) 75%, var(--sls-col-warn-stripe2)); */
background-image: linear-gradient(-45deg,
transparent 25%, rgba(var(--background-secondary), 0.1) 25%, rgba(var(--background-secondary), 0.1) 50%, transparent 50%, transparent 75%, rgba(var(--background-secondary), 0.1) 75%, rgba(var(--background-secondary), 0.1));
background-size: 60px 60px;
@@ -521,8 +525,48 @@ div.workspace-leaf-content[data-type=bases] .livesync-status {
text-align: center;
}
.diff-only-label {
margin-left: 10px;
}
.history-search-row {
display: flex;
gap: 5px;
align-items: center;
margin-bottom: 10px;
}
.history-search-input {
flex-grow: 1;
}
.history-search-result-indicator {
font-size: 0.8em;
min-width: 80px;
}
.history-search-progress-indicator {
font-size: 0.8em;
color: var(--text-muted);
}
.history-diff-options-row {
justify-content: space-between;
}
.history-highlight-diff-container,
.history-highlight-diff-label {
display: flex;
align-items: center;
}
.history-highlight-diff-label {
gap: 4px;
}
.diff-focused {
outline: 2px solid var(--interactive-accent);
outline-offset: 1px;
border-radius: 2px;
}
}

View File

@@ -34,11 +34,11 @@ const terserOption = {
inline: false,
join_vars: true,
loops: true,
passes: 4,
passes: 1,
reduce_vars: true,
reduce_funcs: false,
arrows: true,
collapse_vars: true,
collapse_vars: false,
comparisons: true,
//@ts-ignore
lhs_constants: true,

View File

@@ -2,7 +2,6 @@
"extends": "@tsconfig/svelte/tsconfig.json",
"inlineSourceMap": true,
"compilerOptions": {
"baseUrl": ".",
"module": "ESNext",
"target": "ES2018",
"allowJs": true,
@@ -20,8 +19,8 @@
"strictBindCallApply": true,
"strictFunctionTypes": true,
"paths": {
"@/*": ["src/*"],
"@lib/*": ["src/lib/src/*"]
"@/*": ["./src/*"],
"@lib/*": ["./src/lib/src/*"]
}
},
"include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts"],

View File

@@ -5,8 +5,73 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
## Unreleased
### Under development
- Bulk database fetching is now work in progress. This feature is expected to speed up rebuilds and setups.
Another feature that is needed is the ability to enforce a specific order during the initial comparison between the storage and the local database.
## 0.25.70-patch1
1st June, 2026
This release does not include any changes to behaviour (if everything is as intended).
However, this release had addressed a large number of errors and potential issues caused by the switch to a modern ESLint configuration, as well as unintended log output.
I have also separated out some parts where the type definitions were a bit loose.
As the diff has become too large, I am releasing it as a beta.
To anyone who has submitted a pull request, please bear with me for a little while.
### Refactored
- Many
## 0.25.70
25th May, 2026
### New features
- Implement auto-accept compatible tweak setting and enhance mismatch resolution logic.
- Diff dialogue now has great tools to navigate and understand the differences, including:
- A checkbox to toggle the visibility of collapsed identical sections, making it easier to focus on the actual differences (PR #889).
- A search feature to find specific text in past revisions, and navigate revisions with search results highlighted in the dialogue (PR #890).
- Conflict resolution dialogue now has a navigation feature to jump between conflicts (PR #891).
Thank you so much to @SeleiXi for implementing these features!
### Improved
- More diagnostic information for P2P connections is now shown, including why a connection failure occurred and the current connection status.
## 0.25.69
22nd May, 2026
### Fixed
- No longer does the P2P passphrase mismatch cause a server shutdown.
- Settings related to P2P synchronisation are now correctly applied on start-up and no longer reverted.
### New features
- Diagnostic P2P connection stats are now available.
- These stats indicate the number of connection trials, successes, and failures.
## 0.25.68
22nd May, 2026
### Improved
- P2P connections have improved slightly
- Upgrade to `trystero` v0.24.0, and fixes event handler assignment. This should fix some edge cases where P2P connections fail to establish or messages are not properly handled.
- Weaken terser options to avoid potential issues with minification that could cause runtime errors in some environments.
## ~~0.25.66~~ 0.25.67
20th May, 2026
0.25.66 had a bug that the auto-accept logic for compatible but lossy mismatches was not working as intended.
### New features
- Implement an auto-accept compatible tweak setting and enhance the mismatch resolution logic.
### Improved
- Many messages related to tweak mismatch resolution have been updated for clarity.